dev #68

Merged
hjlee merged 4 commits from dev into main 2025-09-26 17:13:17 +09:00
26 changed files with 3312 additions and 501 deletions

17
.gitignore vendored
View File

@ -274,3 +274,20 @@ out/
bin/
/src/generated/prisma
# 업로드된 파일들 제외
backend-node/uploads/
uploads/
*.jpg
*.jpeg
*.png
*.gif
*.pdf
*.doc
*.docx
*.xls
*.xlsx
*.ppt
*.pptx
*.hwp
*.hwpx

View File

@ -32,6 +32,7 @@ import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
import screenFileRoutes from "./routes/screenFileRoutes";
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
@ -132,6 +133,7 @@ app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);

View File

@ -61,8 +61,41 @@ const storage = multer.diskStorage({
filename: (req, file, cb) => {
// 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨)
const timestamp = Date.now();
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, "_");
console.log("📁 파일명 처리:", {
originalname: file.originalname,
encoding: file.encoding,
mimetype: file.mimetype
});
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
let decodedName;
try {
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
const buffer = Buffer.from(file.originalname, 'latin1');
decodedName = buffer.toString('utf8');
console.log("📁 파일명 디코딩:", { original: file.originalname, decoded: decodedName });
} catch (error) {
// 디코딩 실패 시 원본 사용
decodedName = file.originalname;
console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname);
}
// 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성
// 위험한 문자만 제거: / \ : * ? " < > |
const sanitizedName = decodedName
.replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환
.replace(/\s+/g, "_") // 공백을 언더스코어로 치환
.replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약
const savedFileName = `${timestamp}_${sanitizedName}`;
console.log("📁 파일명 변환:", {
original: file.originalname,
sanitized: sanitizedName,
saved: savedFileName
});
cb(null, savedFileName);
},
});
@ -87,18 +120,64 @@ const upload = multer({
// 기본 허용 파일 타입
const defaultAllowedTypes = [
// 이미지 파일
"image/jpeg",
"image/png",
"image/gif",
"text/html", // HTML 파일 추가
"text/plain", // 텍스트 파일 추가
"image/webp",
"image/svg+xml",
// 텍스트 파일
"text/html",
"text/plain",
"text/markdown",
"text/csv",
"application/json",
"application/xml",
// PDF 파일
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/zip", // ZIP 파일 추가
"application/x-zip-compressed", // ZIP 파일 (다른 MIME 타입)
// Microsoft Office 파일
"application/msword", // .doc
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-excel", // .xls
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"application/vnd.ms-powerpoint", // .ppt
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
// 한컴오피스 파일
"application/x-hwp", // .hwp (한글)
"application/haansofthwp", // .hwp (다른 MIME 타입)
"application/vnd.hancom.hwp", // .hwp (또 다른 MIME 타입)
"application/vnd.hancom.hwpx", // .hwpx (한글 2014+)
"application/x-hwpml", // .hwpml (한글 XML)
"application/vnd.hancom.hcdt", // .hcdt (한셀)
"application/vnd.hancom.hpt", // .hpt (한쇼)
"application/octet-stream", // .hwp, .hwpx (일반적인 바이너리 파일)
// 압축 파일
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/x-7z-compressed",
// 미디어 파일
"video/mp4",
"video/webm",
"video/ogg",
"audio/mp3",
"audio/mpeg",
"audio/wav",
"audio/ogg",
// Apple/맥 파일
"application/vnd.apple.pages", // .pages (Pages)
"application/vnd.apple.numbers", // .numbers (Numbers)
"application/vnd.apple.keynote", // .keynote (Keynote)
"application/x-iwork-pages-sffpages", // .pages (다른 MIME)
"application/x-iwork-numbers-sffnumbers", // .numbers (다른 MIME)
"application/x-iwork-keynote-sffkey", // .keynote (다른 MIME)
"application/vnd.apple.installer+xml", // .pkg (맥 설치 파일)
"application/x-apple-diskimage", // .dmg (맥 디스크 이미지)
// 기타 문서
"application/rtf", // .rtf
"application/vnd.oasis.opendocument.text", // .odt
"application/vnd.oasis.opendocument.spreadsheet", // .ods
"application/vnd.oasis.opendocument.presentation", // .odp
];
if (defaultAllowedTypes.includes(file.mimetype)) {
@ -161,9 +240,20 @@ export const uploadFiles = async (
const savedFiles = [];
for (const file of files) {
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
let decodedOriginalName;
try {
const buffer = Buffer.from(file.originalname, 'latin1');
decodedOriginalName = buffer.toString('utf8');
console.log("💾 DB 저장용 파일명 디코딩:", { original: file.originalname, decoded: decodedOriginalName });
} catch (error) {
decodedOriginalName = file.originalname;
console.log("💾 DB 저장용 파일명 디코딩 실패, 원본 사용:", file.originalname);
}
// 파일 확장자 추출
const fileExt = path
.extname(file.originalname)
.extname(decodedOriginalName)
.toLowerCase()
.replace(".", "");
@ -196,7 +286,7 @@ export const uploadFiles = async (
),
target_objid: finalTargetObjid,
saved_file_name: file.filename,
real_file_name: file.originalname,
real_file_name: decodedOriginalName,
doc_type: docType,
doc_type_name: docTypeName,
file_size: file.size,

View File

@ -0,0 +1,145 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/authMiddleware';
import { PrismaClient } from '@prisma/client';
import logger from '../utils/logger';
const prisma = new PrismaClient();
/**
*
*/
export const getScreenComponentFiles = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId } = req.params;
logger.info(`화면 컴포넌트 파일 조회 시작: screenId=${screenId}`);
// screen_files: 접두사로 해당 화면의 모든 파일 조회
const targetObjidPattern = `screen_files:${screenId}:%`;
const files = await prisma.attach_file_info.findMany({
where: {
target_objid: {
startsWith: `screen_files:${screenId}:`
},
status: 'ACTIVE'
},
orderBy: {
regdate: 'desc'
}
});
// 컴포넌트별로 파일 그룹화
const componentFiles: { [componentId: string]: any[] } = {};
files.forEach(file => {
// target_objid 형식: screen_files:screenId:componentId:fieldName
const targetParts = file.target_objid?.split(':') || [];
if (targetParts.length >= 3) {
const componentId = targetParts[2];
if (!componentFiles[componentId]) {
componentFiles[componentId] = [];
}
componentFiles[componentId].push({
objid: file.objid.toString(),
savedFileName: file.saved_file_name,
realFileName: file.real_file_name,
fileSize: Number(file.file_size),
fileExt: file.file_ext,
filePath: file.file_path,
docType: file.doc_type,
docTypeName: file.doc_type_name,
targetObjid: file.target_objid,
parentTargetObjid: file.parent_target_objid,
writer: file.writer,
regdate: file.regdate?.toISOString(),
status: file.status
});
}
});
logger.info(`화면 컴포넌트 파일 조회 완료: ${Object.keys(componentFiles).length}개 컴포넌트, 총 ${files.length}개 파일`);
res.json({
success: true,
componentFiles: componentFiles,
totalFiles: files.length,
componentCount: Object.keys(componentFiles).length
});
} catch (error) {
logger.error('화면 컴포넌트 파일 조회 오류:', error);
res.status(500).json({
success: false,
message: '화면 컴포넌트 파일 조회 중 오류가 발생했습니다.',
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
}
};
/**
*
*/
export const getComponentFiles = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, componentId } = req.params;
logger.info(`컴포넌트 파일 조회: screenId=${screenId}, componentId=${componentId}`);
// target_objid 패턴: screen_files:screenId:componentId:*
const targetObjidPattern = `screen_files:${screenId}:${componentId}:`;
const files = await prisma.attach_file_info.findMany({
where: {
target_objid: {
startsWith: targetObjidPattern
},
status: 'ACTIVE'
},
orderBy: {
regdate: 'desc'
}
});
const fileList = files.map(file => ({
objid: file.objid.toString(),
savedFileName: file.saved_file_name,
realFileName: file.real_file_name,
fileSize: Number(file.file_size),
fileExt: file.file_ext,
filePath: file.file_path,
docType: file.doc_type,
docTypeName: file.doc_type_name,
targetObjid: file.target_objid,
parentTargetObjid: file.parent_target_objid,
writer: file.writer,
regdate: file.regdate?.toISOString(),
status: file.status
}));
logger.info(`컴포넌트 파일 조회 완료: ${fileList.length}개 파일`);
res.json({
success: true,
files: fileList,
componentId: componentId,
screenId: screenId
});
} catch (error) {
logger.error('컴포넌트 파일 조회 오류:', error);
res.status(500).json({
success: false,
message: '컴포넌트 파일 조회 중 오류가 발생했습니다.',
error: error instanceof Error ? error.message : '알 수 없는 오류'
});
}
};

View File

@ -0,0 +1,13 @@
import { Router } from 'express';
import { authenticateToken } from '../middleware/authMiddleware';
import { getScreenComponentFiles, getComponentFiles } from '../controllers/screenFileController';
const router = Router();
// 화면 컴포넌트별 파일 정보 조회
router.get('/screens/:screenId/components/files', authenticateToken, getScreenComponentFiles);
// 특정 컴포넌트의 파일 목록 조회
router.get('/screens/:screenId/components/:componentId/files', authenticateToken, getComponentFiles);
export default router;

View File

@ -8,11 +8,9 @@ import { ExternalDbConnectionService } from "./externalDbConnectionService";
import { TableManagementService } from "./tableManagementService";
import { ExternalDbConnection } from "../types/externalDbTypes";
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
import { PrismaClient } from "@prisma/client";
import prisma from "../config/database";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
export interface ValidationResult {
isValid: boolean;
error?: string;

View File

@ -1,4 +1,4 @@
import { PrismaClient } from "@prisma/client";
import prisma from "../config/database";
import { logger } from "../utils/logger";
import { cache, CacheKeys } from "../utils/cache";
import {
@ -14,8 +14,6 @@ import { WebType } from "../types/unified-web-types";
import { entityJoinService } from "./entityJoinService";
import { referenceCacheService } from "./referenceCacheService";
const prisma = new PrismaClient();
export class TableManagementService {
constructor() {}

View File

@ -1,4 +1,5 @@
import { LAYOUT_CONFIG } from "@/constants/layout";
import Image from "next/image";
/**
*
@ -6,10 +7,17 @@ import { LAYOUT_CONFIG } from "@/constants/layout";
export function Logo() {
return (
<div className="flex items-center gap-2">
<div className="bg-primary flex h-8 w-8 items-center justify-center rounded-lg">
<span className="text-primary-foreground text-sm font-bold">P</span>
<div className="flex items-center justify-center">
<Image
src="/images/vexplor.png"
alt="WACE 솔루션 로고"
width={120}
height={32}
className="h-8 object-contain"
priority
/>
</div>
<span className="font-semibold">{LAYOUT_CONFIG.COMPANY_NAME}</span>
{/* <span className="font-semibold">{LAYOUT_CONFIG.COMPANY_NAME}</span> */}
</div>
);
}

View File

@ -0,0 +1,611 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Upload,
Download,
Trash2,
Eye,
FileText,
Image,
FileVideo,
FileAudio,
File,
X,
Plus,
Save,
AlertCircle
} from "lucide-react";
import { ComponentData, FileComponent } from "@/types/screen";
import { FileInfo } from "@/lib/registry/components/file-upload/types";
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
import { formatFileSize } from "@/lib/utils";
import { toast } from "sonner";
interface FileAttachmentDetailModalProps {
isOpen: boolean;
onClose: () => void;
component: FileComponent | null;
onUpdateComponent?: (updates: Partial<FileComponent>) => void;
screenId?: string;
tableName?: string;
recordId?: string;
}
/**
*
*/
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <Image className="w-5 h-5" />;
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext)) {
return <FileVideo className="w-5 h-5" />;
}
if (['mp3', 'wav', 'aac', 'flac'].includes(ext)) {
return <FileAudio className="w-5 h-5" />;
}
if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
return <FileText className="w-5 h-5" />;
}
return <File className="w-5 h-5" />;
};
/**
*
* 릿
*/
export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps> = ({
isOpen,
onClose,
component,
onUpdateComponent,
screenId,
tableName,
recordId,
}) => {
// State 관리
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
// 파일 설정 상태
const [fileConfig, setFileConfig] = useState({
docType: "DOCUMENT",
docTypeName: "일반 문서",
accept: "*/*",
maxSize: 10 * 1024 * 1024, // 10MB
multiple: true,
});
// 컴포넌트가 변경될 때 파일 목록 초기화
useEffect(() => {
if (component?.uploadedFiles) {
setUploadedFiles(component.uploadedFiles);
} else {
setUploadedFiles([]);
}
if (component?.fileConfig) {
setFileConfig({
docType: component.fileConfig.docType || "DOCUMENT",
docTypeName: component.fileConfig.docTypeName || "일반 문서",
accept: component.fileConfig.accept?.join(",") || "*/*",
maxSize: (component.fileConfig.maxSize || 10) * 1024 * 1024,
multiple: component.fileConfig.multiple !== false,
});
}
}, [component]);
// 파일 업로드 처리
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
// 파일 검증
const validFiles: File[] = [];
for (const file of fileArray) {
// 크기 체크
if (file.size > fileConfig.maxSize) {
toast.error(`${file.name}: 파일 크기가 너무 큽니다. (최대 ${formatFileSize(fileConfig.maxSize)})`);
continue;
}
// 타입 체크
if (fileConfig.accept && fileConfig.accept !== "*/*") {
const acceptedTypes = fileConfig.accept.split(",").map(type => type.trim());
const isValid = acceptedTypes.some(type => {
if (type.startsWith(".")) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
} else {
return file.type.includes(type);
}
});
if (!isValid) {
toast.error(`${file.name}: 지원하지 않는 파일 형식입니다.`);
continue;
}
}
validFiles.push(file);
if (!fileConfig.multiple) break;
}
if (validFiles.length === 0) return;
try {
setUploading(true);
toast.loading(`${validFiles.length}개 파일 업로드 중...`);
// API를 통한 파일 업로드
const response = await uploadFiles({
files: validFiles,
tableName: tableName || 'screen_files',
fieldName: component?.columnName || component?.id || 'file_attachment',
recordId: recordId || screenId,
});
if (response.success && response.data) {
const newFiles: FileInfo[] = response.data.map((file: any) => ({
objid: file.objid || `temp_${Date.now()}_${Math.random()}`,
savedFileName: file.saved_file_name || file.savedFileName,
realFileName: file.real_file_name || file.realFileName,
fileSize: file.file_size || file.fileSize,
fileExt: file.file_ext || file.fileExt,
filePath: file.file_path || file.filePath,
docType: fileConfig.docType,
docTypeName: fileConfig.docTypeName,
targetObjid: file.target_objid || file.targetObjid || recordId || screenId || '',
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
companyCode: file.company_code || file.companyCode || 'DEFAULT',
writer: file.writer || 'user',
regdate: file.regdate || new Date().toISOString(),
status: file.status || 'ACTIVE',
// 호환성 속성들
path: file.file_path || file.filePath,
name: file.real_file_name || file.realFileName,
id: file.objid,
size: file.file_size || file.fileSize,
type: fileConfig.docType,
uploadedAt: file.regdate || new Date().toISOString(),
}));
const updatedFiles = fileConfig.multiple ? [...uploadedFiles, ...newFiles] : newFiles;
setUploadedFiles(updatedFiles);
// 컴포넌트 업데이트
if (onUpdateComponent) {
onUpdateComponent({
uploadedFiles: updatedFiles,
fileConfig: {
...component?.fileConfig,
docType: fileConfig.docType,
docTypeName: fileConfig.docTypeName,
}
});
}
toast.dismiss();
toast.success(`${validFiles.length}개 파일이 성공적으로 업로드되었습니다.`);
} else {
throw new Error(response.message || '파일 업로드에 실패했습니다.');
}
} catch (error) {
console.error('파일 업로드 오류:', error);
toast.dismiss();
toast.error('파일 업로드에 실패했습니다.');
} finally {
setUploading(false);
}
}, [fileConfig, uploadedFiles, onUpdateComponent, component, tableName, recordId, screenId]);
// 파일 다운로드 처리
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
toast.loading(`${file.realFileName} 다운로드 중...`);
// 로컬 파일인 경우
if (file._file) {
const url = URL.createObjectURL(file._file);
const link = document.createElement("a");
link.href = url;
link.download = file.realFileName || file._file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.dismiss();
toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`);
return;
}
// 서버 파일인 경우
await downloadFile({
fileId: file.objid,
serverFilename: file.savedFileName,
originalName: file.realFileName,
});
toast.dismiss();
toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`);
} catch (error) {
console.error('파일 다운로드 오류:', error);
toast.dismiss();
toast.error('파일 다운로드에 실패했습니다.');
}
}, []);
// 파일 삭제 처리
const handleFileDelete = useCallback(async (file: FileInfo) => {
if (!confirm(`${file.realFileName}을(를) 삭제하시겠습니까?`)) return;
try {
toast.loading(`${file.realFileName} 삭제 중...`);
// 서버 파일인 경우 API 호출
if (!file._file) {
await deleteFile(file.objid, file.savedFileName);
}
// 상태에서 파일 제거
const updatedFiles = uploadedFiles.filter(f => f.objid !== file.objid);
setUploadedFiles(updatedFiles);
// 컴포넌트 업데이트
if (onUpdateComponent) {
onUpdateComponent({
uploadedFiles: updatedFiles,
});
}
toast.dismiss();
toast.success(`${file.realFileName}이 삭제되었습니다.`);
} catch (error) {
console.error('파일 삭제 오류:', error);
toast.dismiss();
toast.error('파일 삭제에 실패했습니다.');
}
}, [uploadedFiles, onUpdateComponent]);
// 파일뷰어 열기
const handleFileView = useCallback((file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
}, []);
// 파일뷰어 닫기
const handleViewerClose = useCallback(() => {
setIsViewerOpen(false);
setViewerFile(null);
}, []);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFileUpload(files);
}
}, [handleFileUpload]);
// 파일 선택 처리
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFileUpload(files);
}
// 같은 파일을 다시 선택할 수 있도록 value 초기화
e.target.value = '';
}, [handleFileUpload]);
// 설정 저장
const handleSaveSettings = useCallback(() => {
if (onUpdateComponent) {
onUpdateComponent({
fileConfig: {
...component?.fileConfig,
docType: fileConfig.docType,
docTypeName: fileConfig.docTypeName,
accept: fileConfig.accept.split(",").map(type => type.trim()),
maxSize: Math.floor(fileConfig.maxSize / (1024 * 1024)), // MB로 변환
multiple: fileConfig.multiple,
}
});
}
toast.success('설정이 저장되었습니다.');
}, [fileConfig, onUpdateComponent, component]);
if (!component) return null;
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="text-xl font-semibold">
- {component.label || component.id}
</DialogTitle>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
</DialogHeader>
<Tabs defaultValue="files" className="flex-1">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="files"> </TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
</TabsList>
{/* 파일 관리 탭 */}
<TabsContent value="files" className="flex-1 overflow-auto space-y-4">
{/* 파일 업로드 영역 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Upload className="w-5 h-5" />
<span> </span>
</CardTitle>
</CardHeader>
<CardContent>
<div
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !uploading && document.getElementById('file-input')?.click()}
>
<input
id="file-input"
type="file"
multiple={fileConfig.multiple}
accept={fileConfig.accept}
onChange={handleFileSelect}
className="hidden"
disabled={uploading}
/>
<div className="flex flex-col items-center space-y-3">
{uploading ? (
<>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="text-lg font-medium text-gray-700"> ...</p>
</>
) : (
<>
<Upload className="w-12 h-12 text-gray-400" />
<div>
<p className="text-lg font-medium text-gray-700">
</p>
<p className="text-sm text-gray-500 mt-2">
{fileConfig.accept && `지원 형식: ${fileConfig.accept}`}
{fileConfig.maxSize && ` • 최대 ${formatFileSize(fileConfig.maxSize)}`}
{fileConfig.multiple && ' • 여러 파일 선택 가능'}
</p>
</div>
<Button variant="outline">
<Plus className="w-4 h-4 mr-2" />
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
{/* 업로드된 파일 목록 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<FileText className="w-5 h-5" />
<span> ({uploadedFiles.length})</span>
</CardTitle>
{uploadedFiles.length > 0 && (
<Badge variant="secondary">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{uploadedFiles.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p> .</p>
<p className="text-sm mt-1"> .</p>
</div>
) : (
<div className="space-y-3 max-h-60 overflow-y-auto">
{uploadedFiles.map((file) => (
<div key={file.objid} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{file.realFileName}
</p>
<div className="flex items-center space-x-2 text-xs text-gray-500">
<span>{formatFileSize(file.fileSize)}</span>
<span></span>
<span>{file.fileExt.toUpperCase()}</span>
{file.uploadedAt && (
<>
<span></span>
<span>{new Date(file.uploadedAt).toLocaleDateString()}</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-1 flex-shrink-0">
{/* 파일뷰어 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => handleFileView(file)}
className="h-8 w-8 p-0"
title="미리보기"
>
<Eye className="w-4 h-4" />
</Button>
{/* 다운로드 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(file)}
className="h-8 w-8 p-0"
title="다운로드"
>
<Download className="w-4 h-4" />
</Button>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDelete(file)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* 설정 탭 */}
<TabsContent value="settings" className="flex-1 overflow-auto space-y-4">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="docType"> </Label>
<Input
id="docType"
value={fileConfig.docType}
onChange={(e) => setFileConfig(prev => ({ ...prev, docType: e.target.value }))}
placeholder="예: DOCUMENT, IMAGE, etc."
/>
</div>
<div>
<Label htmlFor="docTypeName"> </Label>
<Input
id="docTypeName"
value={fileConfig.docTypeName}
onChange={(e) => setFileConfig(prev => ({ ...prev, docTypeName: e.target.value }))}
placeholder="예: 일반 문서, 이미지 파일"
/>
</div>
</div>
<div>
<Label htmlFor="accept"> </Label>
<Input
id="accept"
value={fileConfig.accept}
onChange={(e) => setFileConfig(prev => ({ ...prev, accept: e.target.value }))}
placeholder="예: image/*,.pdf,.doc,.docx"
/>
<p className="text-xs text-gray-500 mt-1">
(: image/*,.pdf,.doc)
</p>
</div>
<div>
<Label htmlFor="maxSize"> (MB)</Label>
<Input
id="maxSize"
type="number"
value={Math.floor(fileConfig.maxSize / (1024 * 1024))}
onChange={(e) => setFileConfig(prev => ({
...prev,
maxSize: parseInt(e.target.value) * 1024 * 1024
}))}
min="1"
max="100"
/>
</div>
<div className="flex items-center space-x-2">
<input
id="multiple"
type="checkbox"
checked={fileConfig.multiple}
onChange={(e) => setFileConfig(prev => ({ ...prev, multiple: e.target.checked }))}
className="rounded border-gray-300"
/>
<Label htmlFor="multiple"> </Label>
</div>
<div className="pt-4 border-t">
<Button onClick={handleSaveSettings} className="w-full">
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
{/* 파일뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
/>
</>
);
};

View File

@ -509,6 +509,48 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
fetchCurrentUser();
}, []);
// 파일 상태 새로고침 이벤트 리스너
useEffect(() => {
const handleRefreshFileStatus = async (event: CustomEvent) => {
const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail;
console.log("🔄 InteractiveDataTable 파일 상태 새로고침 이벤트 수신:", {
tableName,
recordId,
columnName,
targetObjid,
fileCount,
currentTableName: component.tableName
});
// 현재 테이블과 일치하는지 확인
if (tableName === component.tableName) {
// 해당 행의 파일 상태 업데이트
const columnKey = `${recordId}_${columnName}`;
setFileStatusMap(prev => ({
...prev,
[recordId]: { hasFiles: fileCount > 0, fileCount },
[columnKey]: { hasFiles: fileCount > 0, fileCount }
}));
console.log("✅ 파일 상태 업데이트 완료:", {
recordId,
columnKey,
hasFiles: fileCount > 0,
fileCount
});
}
};
if (typeof window !== 'undefined') {
window.addEventListener('refreshFileStatus', handleRefreshFileStatus as EventListener);
return () => {
window.removeEventListener('refreshFileStatus', handleRefreshFileStatus as EventListener);
};
}
}, [component.tableName]);
// 테이블 컬럼 정보 로드 (웹 타입 정보 포함)
useEffect(() => {
const fetchTableColumns = async () => {

View File

@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner";
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
@ -412,40 +413,46 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const { label, readonly } = comp;
const fieldName = comp.columnName || comp.id;
const handleFileUpload = async (files: File[]) => {
if (!screenInfo?.tableName) {
toast.error("테이블명이 설정되지 않았습니다.");
return;
}
try {
const uploadData = {
files,
tableName: screenInfo.tableName,
fieldName,
recordId: formData.id || undefined,
};
const response = await uploadFilesAndCreateData(uploadData);
if (response.success) {
toast.success("파일이 성공적으로 업로드되었습니다.");
handleFormDataChange(fieldName, response.data);
} else {
toast.error("파일 업로드에 실패했습니다.");
}
} catch (error) {
console.error("파일 업로드 오류:", error);
toast.error("파일 업로드 중 오류가 발생했습니다.");
}
};
return (
<div className="h-full w-full">
{/* 파일 업로드 컴포넌트는 기존 구현 사용 */}
<div className="rounded border border-dashed p-2 text-sm text-gray-500">
( )
</div>
{/* 실제 FileUploadComponent 사용 */}
<FileUploadComponent
component={comp}
componentConfig={{
...comp.fileConfig,
multiple: comp.fileConfig?.multiple !== false,
accept: comp.fileConfig?.accept || "*/*",
maxSize: (comp.fileConfig?.maxSize || 10) * 1024 * 1024, // MB to bytes
disabled: readonly,
}}
componentStyle={{
width: '100%',
height: '100%',
}}
className="h-full w-full"
isInteractive={true}
isDesignMode={false}
formData={{
tableName: screenInfo?.tableName,
id: formData.id,
...formData
}}
onFormDataChange={(data) => {
console.log("📝 파일 업로드 완료:", data);
if (onFormDataChange) {
Object.entries(data).forEach(([key, value]) => {
onFormDataChange(key, value);
});
}
}}
onUpdate={(updates) => {
console.log("🔄 파일 컴포넌트 업데이트:", updates);
// 파일 업로드 완료 시 formData 업데이트
if (updates.uploadedFiles && onFormDataChange) {
onFormDataChange(fieldName, updates.uploadedFiles);
}
}}
/>
</div>
);
};

View File

@ -43,6 +43,12 @@ import {
SidebarOpen,
Folder,
ChevronUp,
Image as ImageIcon,
FileText,
Video,
Music,
Archive,
Presentation,
} from "lucide-react";
interface RealtimePreviewProps {
@ -303,17 +309,92 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
)}
{/* 파일 타입 */}
{type === "file" && (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4">
<div className="flex h-full flex-col items-center justify-center text-center">
<File className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm text-gray-600"> </p>
<p className="mt-1 text-xs text-gray-400"> </p>
{type === "file" && (() => {
const fileComponent = component as any;
const uploadedFiles = fileComponent.uploadedFiles || [];
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
componentId: component.id,
uploadedFilesCount: uploadedFiles.length,
globalFilesCount: globalFiles.length,
currentFilesCount: currentFiles.length,
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
componentType: component.type,
timestamp: new Date().toISOString()
});
return (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
{currentFiles.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="mb-1 text-xs font-medium text-gray-700">
({currentFiles.length})
</div>
<div className="space-y-1">
{currentFiles.map((file: any, index: number) => {
// 파일 확장자에 따른 아이콘 선택
const getFileIcon = (fileName: string) => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <ImageIcon className="h-4 w-4 text-green-500 flex-shrink-0" />;
}
if (['pdf', 'doc', 'docx', 'txt', 'rtf', 'hwp', 'hwpx', 'hwpml', 'pages'].includes(ext)) {
return <FileText className="h-4 w-4 text-red-500 flex-shrink-0" />;
}
if (['ppt', 'pptx', 'hpt', 'keynote'].includes(ext)) {
return <Presentation className="h-4 w-4 text-orange-600 flex-shrink-0" />;
}
if (['xls', 'xlsx', 'hcdt', 'numbers'].includes(ext)) {
return <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />;
}
if (['mp4', 'avi', 'mov', 'wmv', 'webm', 'ogg'].includes(ext)) {
return <Video className="h-4 w-4 text-purple-500 flex-shrink-0" />;
}
if (['mp3', 'wav', 'flac', 'aac'].includes(ext)) {
return <Music className="h-4 w-4 text-orange-500 flex-shrink-0" />;
}
if (['zip', 'rar', '7z', 'tar'].includes(ext)) {
return <Archive className="h-4 w-4 text-yellow-500 flex-shrink-0" />;
}
return <File className="h-4 w-4 text-blue-500 flex-shrink-0" />;
};
return (
<div key={file.objid || index} className="flex items-center space-x-2 bg-white rounded p-2 text-xs">
{getFileIcon(file.realFileName || file.name || '')}
<div className="flex-1 min-w-0">
<p className="truncate font-medium text-gray-900">
{file.realFileName || file.name || `파일 ${index + 1}`}
</p>
<p className="text-gray-500">
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ''}
</p>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<File className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-xs font-medium text-gray-700 mb-1"> (0)</p>
<p className="text-sm text-gray-600"> </p>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
)}
</div>
</div>
</div>
)}
);
})()}
</div>
{/* 선택된 컴포넌트 정보 표시 */}

View File

@ -25,6 +25,7 @@ interface RealtimePreviewProps {
isSelected?: boolean;
isDesignMode?: boolean; // 편집 모드 여부
onClick?: (e?: React.MouseEvent) => void;
onDoubleClick?: (e?: React.MouseEvent) => void; // 더블클릭 핸들러 추가
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
@ -67,6 +68,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
isSelected = false,
isDesignMode = true, // 기본값은 편집 모드
onClick,
onDoubleClick,
onDragStart,
onDragEnd,
onGroupToggle,
@ -106,6 +108,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onClick?.(e);
};
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(e);
};
const handleDragStart = (e: React.DragEvent) => {
e.stopPropagation();
onDragStart?.(e);
@ -121,6 +128,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
className="absolute cursor-pointer"
style={{ ...baseStyle, ...selectionStyle }}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}

View File

@ -39,7 +39,9 @@ import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
import { initializeComponents } from "@/lib/registry/components";
import { ScreenFileAPI } from "@/lib/api/screenFile";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic";
@ -157,6 +159,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 메뉴 할당 모달 상태
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
// 파일첨부 상세 모달 상태
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
// 해상도 설정 상태
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
@ -188,6 +194,88 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용
});
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
// 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회)
const restoreFileComponentsData = useCallback(async (components: ComponentData[]) => {
if (!selectedScreen?.screenId) return;
console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
try {
// 실제 DB에서 화면의 모든 파일 정보 조회
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
if (!fileResponse.success) {
console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
return;
}
const { componentFiles } = fileResponse;
if (typeof window !== 'undefined') {
// 전역 파일 상태 초기화
const globalFileState: {[key: string]: any[]} = {};
let restoredCount = 0;
// DB에서 조회한 파일 정보를 전역 상태로 복원
Object.keys(componentFiles).forEach(componentId => {
const files = componentFiles[componentId];
if (files && files.length > 0) {
globalFileState[componentId] = files;
restoredCount++;
// localStorage에도 백업
const backupKey = `fileComponent_${componentId}_files`;
localStorage.setItem(backupKey, JSON.stringify(files));
console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", {
componentId: componentId,
fileCount: files.length,
files: files.map(f => ({ objid: f.objid, name: f.realFileName }))
});
}
});
// 전역 상태 업데이트
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 복원 완료 이벤트 발생
Object.keys(globalFileState).forEach(componentId => {
const files = globalFileState[componentId];
const syncEvent = new CustomEvent('globalFileStateChanged', {
detail: {
componentId: componentId,
files: files,
fileCount: files.length,
timestamp: Date.now(),
isRestore: true
}
});
window.dispatchEvent(syncEvent);
});
console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", {
totalComponents: components.length,
restoredFileComponents: restoredCount,
totalFiles: fileResponse.totalFiles,
globalFileState: Object.keys(globalFileState).map(id => ({
id,
fileCount: globalFileState[id]?.length || 0
}))
});
if (restoredCount > 0) {
toast.success(`${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`);
}
}
} catch (error) {
console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
}
}, [selectedScreen?.screenId]);
// 드래그 선택 상태
const [selectionDrag, setSelectionDrag] = useState({
isSelecting: false,
@ -646,6 +734,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
initComponents();
}, []);
// 전역 파일 상태 변경 이벤트 리스너
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail);
setForceRenderTrigger(prev => prev + 1);
};
if (typeof window !== 'undefined') {
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
return () => {
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
};
}
}, []);
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
useEffect(() => {
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
@ -698,6 +802,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
// 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용)
if (typeof window !== 'undefined') {
(window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId;
}
const loadLayout = async () => {
try {
const response = await screenApi.getLayout(selectedScreen.screenId);
@ -732,6 +841,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
@ -1986,6 +2098,56 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
[layout, gridInfo, saveToHistory, openPanel],
);
// 파일 컴포넌트 업데이트 처리
const handleFileComponentUpdate = useCallback(
(updates: Partial<ComponentData>) => {
if (!selectedFileComponent) return;
const updatedComponents = layout.components.map(comp =>
comp.id === selectedFileComponent.id
? { ...comp, ...updates }
: comp
);
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
// selectedFileComponent도 업데이트
setSelectedFileComponent(prev => prev ? { ...prev, ...updates } : null);
// selectedComponent가 같은 컴포넌트라면 업데이트
if (selectedComponent?.id === selectedFileComponent.id) {
setSelectedComponent(prev => prev ? { ...prev, ...updates } : null);
}
},
[selectedFileComponent, layout, saveToHistory, selectedComponent],
);
// 파일첨부 모달 닫기
const handleFileAttachmentModalClose = useCallback(() => {
setShowFileAttachmentModal(false);
setSelectedFileComponent(null);
}, []);
// 컴포넌트 더블클릭 처리
const handleComponentDoubleClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
// 파일 컴포넌트인 경우 상세 모달 열기
if (component.type === "file") {
setSelectedFileComponent(component);
setShowFileAttachmentModal(true);
return;
}
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
console.log("더블클릭된 컴포넌트:", component.type, component.id);
},
[],
);
// 컴포넌트 클릭 처리 (다중선택 지원)
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
@ -3276,15 +3438,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
const componentFiles = (component as any).uploadedFiles || [];
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
return (
<RealtimePreview
key={component.id}
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
component={displayComponent}
isSelected={
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(component, e)}
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
@ -3389,13 +3558,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
return (
<RealtimePreview
key={child.id}
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`}
component={relativeChildComponent}
isSelected={
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => handleComponentClick(child, e)}
onDoubleClick={(e) => handleComponentDoubleClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
@ -3725,6 +3895,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}}
onBackToList={onBackToList}
/>
{/* 파일첨부 상세 모달 */}
<FileAttachmentDetailModal
isOpen={showFileAttachmentModal}
onClose={handleFileAttachmentModalClose}
component={selectedFileComponent}
onUpdateComponent={handleFileComponentUpdate}
screenId={selectedScreen?.screenId}
tableName={selectedScreen?.tableName}
recordId={selectedScreen?.screenId}
/>
</div>
);
}

View File

@ -908,7 +908,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
if (selectedComponent.type === "file") {
if (selectedComponent.type === "file" || (selectedComponent.type === "widget" && selectedComponent.widgetType === "file")) {
const fileComponent = selectedComponent as FileComponent;
return (
@ -923,7 +923,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800"> </span>
</div>
<div className="mt-1 text-xs text-gray-500"> : {fileComponent.fileConfig.docTypeName}</div>
<div className="mt-1 text-xs text-gray-500">
{selectedComponent.type === "widget" ? `위젯타입: ${selectedComponent.widgetType}` : `문서 타입: ${fileComponent.fileConfig?.docTypeName || "일반 문서"}`}
</div>
</div>
{/* 파일 컴포넌트 설정 영역 */}

File diff suppressed because it is too large Load Diff

View File

@ -20,9 +20,73 @@ interface FileUploadProps {
export function FileUpload({ component, onUpdateComponent, onFileUpload, userInfo }: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
const [localUploadedFiles, setLocalUploadedFiles] = useState<AttachedFileInfo[]>(component.uploadedFiles || []);
// 전역 파일 상태 관리 함수들
const getGlobalFileState = (): {[key: string]: any[]} => {
if (typeof window !== 'undefined') {
return (window as any).globalFileState || {};
}
return {};
};
const setGlobalFileState = (updater: (prev: {[key: string]: any[]}) => {[key: string]: any[]}) => {
if (typeof window !== 'undefined') {
const currentState = getGlobalFileState();
const newState = updater(currentState);
(window as any).globalFileState = newState;
console.log("🌐 FileUpload 전역 파일 상태 업데이트:", {
componentId: component.id,
newFileCount: newState[component.id]?.length || 0
});
// 강제 리렌더링을 위한 이벤트 발생
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { componentId: component.id, fileCount: newState[component.id]?.length || 0 }
}));
}
};
// 초기 파일 상태 설정 (전역 상태 우선)
const initializeFiles = () => {
const globalFiles = getGlobalFileState()[component.id] || [];
const componentFiles = component.uploadedFiles || [];
const finalFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
console.log("🚀 FileUpload 파일 상태 초기화:", {
componentId: component.id,
globalFiles: globalFiles.length,
componentFiles: componentFiles.length,
finalFiles: finalFiles.length
});
return finalFiles;
};
const [localUploadedFiles, setLocalUploadedFiles] = useState<AttachedFileInfo[]>(initializeFiles());
const fileInputRef = useRef<HTMLInputElement>(null);
// 전역 상태 변경 감지
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
if (event.detail.componentId === component.id) {
const globalFiles = getGlobalFileState()[component.id] || [];
console.log("🔄 FileUpload 전역 상태 변경 감지:", {
componentId: component.id,
newFileCount: globalFiles.length
});
setLocalUploadedFiles(globalFiles);
}
};
if (typeof window !== 'undefined') {
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
return () => {
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
};
}
}, [component.id]);
const { fileConfig } = component;
const { user: authUser, isLoading, isLoggedIn } = useAuth(); // 인증 상태도 함께 가져오기
@ -434,6 +498,12 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
// 로컬 상태 업데이트
setLocalUploadedFiles(updatedFiles);
// 전역 상태 업데이트
setGlobalFileState(prev => ({
...prev,
[component.id]: updatedFiles
}));
// 컴포넌트 업데이트 (옵셔널)
if (onUpdateComponent) {
onUpdateComponent({
@ -507,6 +577,12 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
// 로컬 상태 업데이트
setLocalUploadedFiles(filteredFiles);
// 전역 상태 업데이트
setGlobalFileState(prev => ({
...prev,
[component.id]: filteredFiles
}));
onUpdateComponent({
uploadedFiles: filteredFiles,
});

View File

@ -27,13 +27,43 @@ export interface FileDownloadParams {
/**
*
*/
export const uploadFiles = async (files: FileList): Promise<FileUploadResponse> => {
export const uploadFiles = async (params: {
files: FileList | File[];
tableName?: string;
fieldName?: string;
recordId?: string;
docType?: string;
docTypeName?: string;
targetObjid?: string;
parentTargetObjid?: string;
linkedTable?: string;
linkedField?: string;
autoLink?: boolean;
columnName?: string;
isVirtualFileColumn?: boolean;
}): Promise<FileUploadResponse> => {
const formData = new FormData();
Array.from(files).forEach((file) => {
// 파일 추가
const fileArray = Array.isArray(params.files) ? params.files : Array.from(params.files);
fileArray.forEach((file) => {
formData.append("files", file);
});
// 추가 파라미터들 추가
if (params.tableName) formData.append("tableName", params.tableName);
if (params.fieldName) formData.append("fieldName", params.fieldName);
if (params.recordId) formData.append("recordId", params.recordId);
if (params.docType) formData.append("docType", params.docType);
if (params.docTypeName) formData.append("docTypeName", params.docTypeName);
if (params.targetObjid) formData.append("targetObjid", params.targetObjid);
if (params.parentTargetObjid) formData.append("parentTargetObjid", params.parentTargetObjid);
if (params.linkedTable) formData.append("linkedTable", params.linkedTable);
if (params.linkedField) formData.append("linkedField", params.linkedField);
if (params.autoLink !== undefined) formData.append("autoLink", params.autoLink.toString());
if (params.columnName) formData.append("columnName", params.columnName);
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
const response = await apiClient.post("/files/upload", formData, {
headers: {
"Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록

View File

@ -0,0 +1,49 @@
import { apiClient } from './client';
export interface ScreenFileInfo {
objid: string;
savedFileName: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
docType: string;
docTypeName: string;
targetObjid: string;
parentTargetObjid?: string;
writer: string;
regdate: string;
status: string;
}
export interface ScreenComponentFilesResponse {
success: boolean;
componentFiles: { [componentId: string]: ScreenFileInfo[] };
totalFiles: number;
componentCount: number;
}
export interface ComponentFilesResponse {
success: boolean;
files: ScreenFileInfo[];
componentId: string;
screenId: string;
}
export const ScreenFileAPI = {
/**
*
*/
async getScreenComponentFiles(screenId: number): Promise<ScreenComponentFilesResponse> {
const response = await apiClient.get(`/screen-files/screens/${screenId}/components/files`);
return response.data;
},
/**
*
*/
async getComponentFiles(screenId: number, componentId: string): Promise<ComponentFilesResponse> {
const response = await apiClient.get(`/screen-files/screens/${screenId}/components/${componentId}/files`);
return response.data;
}
};

View File

@ -52,11 +52,19 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
try {
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
console.log(`DB 웹타입 정보:`, dbWebType);
console.log(`웹타입 데이터 배열:`, webTypes);
// FileWidget의 경우 FileUploadComponent 직접 사용
if (dbWebType.component_name === "FileWidget" || webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`✅ FileWidget → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />;
}
// 다른 컴포넌트들은 기존 로직 유지
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
// return <ComponentByName {...props} {...finalProps} />;
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화`);
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
return <div> ...</div>;
} catch (error) {
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
@ -67,6 +75,13 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
if (webTypeDefinition) {
console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`✅ 파일 웹타입 → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />;
}
// 웹타입이 비활성화된 경우
if (!webTypeDefinition.isActive) {
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
@ -91,6 +106,14 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 3순위: 웹타입명으로 자동 매핑 (폴백)
try {
console.warn(`웹타입 "${webType}" → 자동 매핑 폴백 사용`);
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`✅ 폴백: 파일 웹타입 → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />;
}
// const FallbackComponent = getWidgetComponentByWebType(webType);
// return <FallbackComponent {...props} />;
console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`);

View File

@ -35,9 +35,12 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
// 동적 웹타입 렌더링 사용
if (widgetType) {
try {
// 파일 위젯의 경우 인터랙션 허용 (pointer-events-none 제거)
const isFileWidget = widgetType === "file";
return (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">
<div className={isFileWidget ? "flex-1" : "pointer-events-none flex-1"}>
<DynamicWebTypeRenderer
webType={widgetType}
props={{
@ -45,6 +48,7 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
component: widget,
value: undefined, // 미리보기이므로 값은 없음
readonly: readonly,
isDesignMode: true, // 디자인 모드임을 명시
}}
config={widget.webTypeConfig}
/>

View File

@ -1,81 +1,568 @@
"use client";
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 } from "@/lib/api/file";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
import {
Upload,
File,
FileText,
Image,
Video,
Music,
Archive,
Download,
Eye,
Trash2,
AlertCircle,
FileImage,
FileVideo,
FileAudio,
Presentation,
} from "lucide-react";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { FileUploadConfig } from "./types";
// 파일 아이콘 매핑
const getFileIcon = (extension: string) => {
const ext = extension.toLowerCase().replace('.', '');
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
return <FileImage className="w-6 h-6 text-blue-500" />;
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(ext)) {
return <FileVideo className="w-6 h-6 text-purple-500" />;
}
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext)) {
return <FileAudio className="w-6 h-6 text-green-500" />;
}
if (['pdf'].includes(ext)) {
return <FileText className="w-6 h-6 text-red-500" />;
}
if (['doc', 'docx', 'hwp', 'hwpx', 'pages'].includes(ext)) {
return <FileText className="w-6 h-6 text-blue-600" />;
}
if (['xls', 'xlsx', 'hcell', 'numbers'].includes(ext)) {
return <FileText className="w-6 h-6 text-green-600" />;
}
if (['ppt', 'pptx', 'hanshow', 'keynote'].includes(ext)) {
return <Presentation className="w-6 h-6 text-orange-500" />;
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
return <Archive className="w-6 h-6 text-gray-500" />;
}
return <File className="w-6 h-6 text-gray-400" />;
};
export interface FileUploadComponentProps extends ComponentRendererProps {
config?: FileUploadConfig;
export interface FileUploadComponentProps {
component: any;
componentConfig: FileUploadConfig;
componentStyle: React.CSSProperties;
className: string;
isInteractive: boolean;
isDesignMode: boolean;
formData: any;
onFormDataChange: (data: 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;
}
/**
* FileUpload
* file-upload
*/
export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
componentConfig,
componentStyle,
className,
isInteractive,
isDesignMode,
formData,
onFormDataChange,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
...props
onUpdate,
}) => {
// 컴포넌트 설정
const componentConfig = {
...config,
...component.config,
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 [forceUpdate, setForceUpdate] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// 컴포넌트 파일 동기화
useEffect(() => {
const componentFiles = (component as any)?.uploadedFiles || [];
const lastUpdate = (component as any)?.lastFileUpdate;
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
console.log("🔄 FileUploadComponent 파일 동기화:", {
componentId: component.id,
componentFiles: componentFiles.length,
globalFiles: globalFiles.length,
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
lastUpdate: lastUpdate
});
// localStorage에서 백업 파일 복원
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles && currentFiles.length === 0) {
const parsedFiles = JSON.parse(backupFiles);
setUploadedFiles(parsedFiles);
return;
}
} catch (e) {
console.warn("localStorage 백업 복원 실패:", e);
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(currentFiles);
setForceUpdate(prev => prev + 1);
}
}, [component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
const { componentId, files, fileCount, timestamp, isRestore } = event.detail;
console.log("🔄 FileUploadComponent 전역 상태 변경 감지:", {
currentComponentId: component.id,
eventComponentId: componentId,
isForThisComponent: componentId === component.id,
newFileCount: fileCount,
currentFileCount: uploadedFiles.length,
timestamp,
isRestore: !!isRestore
});
// 같은 컴포넌트 ID인 경우에만 업데이트
if (componentId === component.id) {
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
console.log(logMessage, {
componentId: component.id,
이전파일수: uploadedFiles.length,
새파일수: files.length,
files: files.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(files);
setForceUpdate(prev => prev + 1);
// localStorage 백업도 업데이트
try {
const backupKey = `fileUpload_${component.id}`;
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, uploadedFiles.length]);
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
const safeComponentConfig = componentConfig || {};
const fileConfig = {
accept: safeComponentConfig.accept || "*/*",
multiple: safeComponentConfig.multiple || false,
maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB
maxFiles: safeComponentConfig.maxFiles || 5,
...safeComponentConfig
} as FileUploadConfig;
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
};
// 파일 선택 핸들러
const handleFileSelect = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFileUpload(files);
}
}, []);
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
// 파일 업로드 처리
const handleFileUpload = useCallback(async (files: File[]) => {
if (!files.length) return;
// 중복 파일 체크
const existingFileNames = uploadedFiles.map(f => f.realFileName.toLowerCase());
const duplicates: string[] = [];
const uniqueFiles: File[] = [];
console.log("🔍 중복 파일 체크:", {
uploadedFiles: uploadedFiles.length,
existingFileNames: existingFileNames,
newFiles: files.map(f => f.name.toLowerCase())
});
files.forEach(file => {
const fileName = file.name.toLowerCase();
if (existingFileNames.includes(fileName)) {
duplicates.push(file.name);
console.log("❌ 중복 파일 발견:", file.name);
} else {
uniqueFiles.push(file);
console.log("✅ 새로운 파일:", file.name);
}
});
console.log("🔍 중복 체크 결과:", {
duplicates: duplicates,
uniqueFiles: uniqueFiles.map(f => f.name)
});
if (duplicates.length > 0) {
toast.error(`중복된 파일이 있습니다: ${duplicates.join(', ')}`, {
description: "같은 이름의 파일이 이미 업로드되어 있습니다.",
duration: 4000
});
if (uniqueFiles.length === 0) {
return; // 모든 파일이 중복이면 업로드 중단
}
// 일부만 중복인 경우 고유한 파일만 업로드
toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`);
}
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
setUploadStatus('uploading');
toast.loading("파일을 업로드하는 중...", { id: 'file-upload' });
try {
// targetObjid 생성 (InteractiveDataTable과 호환)
const tableName = formData?.tableName || component.tableName || 'default_table';
const recordId = formData?.id || 'temp_record';
const columnName = component.columnName || component.id;
const targetObjid = `${tableName}:${recordId}:${columnName}`;
const uploadData = {
tableName: tableName,
fieldName: columnName,
recordId: recordId,
docType: component.fileConfig?.docType || 'DOCUMENT',
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
columnName: columnName, // 가상 파일 컬럼 지원
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
};
console.log("📤 파일 업로드 시작:", {
originalFiles: files.length,
filesToUpload: filesToUpload.length,
files: filesToUpload.map(f => ({ name: f.name, size: f.size })),
uploadData
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData
});
console.log("📤 파일 업로드 API 응답:", response);
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용
const fileData = response.files || (response as any).data || [];
console.log("📁 파일 데이터 확인:", {
hasFiles: !!response.files,
hasData: !!(response as any).data,
fileDataLength: fileData.length,
fileData: fileData,
responseKeys: Object.keys(response)
});
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
}));
console.log("📁 변환된 파일 데이터:", newFiles);
const updatedFiles = [...uploadedFiles, ...newFiles];
console.log("🔄 파일 상태 업데이트:", {
이전파일수: uploadedFiles.length,
새파일수: newFiles.length,
총파일수: updatedFiles.length,
updatedFiles: updatedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(updatedFiles);
setUploadStatus('success');
// localStorage 백업
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== 'undefined') {
// 전역 파일 상태 업데이트
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent('globalFileStateChanged', {
detail: {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now()
}
});
window.dispatchEvent(syncEvent);
console.log("🌐 전역 파일 상태 업데이트 및 동기화 이벤트 발생:", {
componentId: component.id,
fileCount: updatedFiles.length,
globalState: Object.keys(globalFileState).map(id => ({
id,
fileCount: globalFileState[id]?.length || 0
}))
});
}
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
console.log("🔄 onUpdate 호출:", {
componentId: component.id,
uploadedFiles: updatedFiles.length,
timestamp: timestamp
});
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp
});
} else {
console.warn("⚠️ onUpdate 콜백이 없습니다!");
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== 'undefined') {
const refreshEvent = new CustomEvent('refreshFileStatus', {
detail: {
tableName: tableName,
recordId: recordId,
columnName: columnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length
}
});
window.dispatchEvent(refreshEvent);
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
tableName,
recordId,
columnName,
targetObjid,
fileCount: updatedFiles.length
});
}
// 폼 데이터 업데이트
if (onFormDataChange && component.columnName) {
const fileIds = updatedFiles.map(f => f.objid);
onFormDataChange({
...formData,
[component.columnName]: fileIds
});
}
// 컴포넌트 설정 콜백
if (safeComponentConfig.onFileUpload) {
safeComponentConfig.onFileUpload(newFiles);
}
} else {
console.error("❌ 파일 업로드 실패:", response);
throw new Error(response.message || (response as any).error || '파일 업로드에 실패했습니다.');
}
} catch (error) {
console.error('파일 업로드 오류:', error);
setUploadStatus('error');
toast.dismiss();
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
}
}, [safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData]);
// 파일 다운로드
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
await downloadFile(file.objid, 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;
await deleteFile(fileId);
const updatedFiles = uploadedFiles.filter(f => f.objid !== fileId);
setUploadedFiles(updatedFiles);
// localStorage 백업 업데이트
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== 'undefined') {
// 전역 파일 상태 업데이트
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent('globalFileStateChanged', {
detail: {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now()
}
});
window.dispatchEvent(syncEvent);
console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", {
componentId: component.id,
deletedFile: fileName,
remainingFiles: updatedFiles.length
});
}
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp
});
}
toast.success(`${fileName} 삭제 완료`);
} catch (error) {
console.error('파일 삭제 오류:', error);
toast.error('파일 삭제에 실패했습니다.');
}
}, [uploadedFiles, onUpdate, component.id]);
// 파일 뷰어
const handleFileView = useCallback((file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
}, []);
const handleViewerClose = useCallback(() => {
setIsViewerOpen(false);
setViewerFile(null);
}, []);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
onClick?.();
};
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
setDragOver(true);
}
}, [safeComponentConfig.readonly, safeComponentConfig.disabled]);
// DOM에 전달하면 안 되는 React-specific props 필터링
const {
selectedScreen,
onZoneComponentDrop,
onZoneClick,
componentConfig: _componentConfig,
component: _component,
isSelected: _isSelected,
onClick: _onClick,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
size: _size,
position: _position,
style: _style,
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
...domProps
} = props;
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]);
return (
<div style={componentStyle} className={className} {...domProps}>
<div style={componentStyle} className={className}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label
@ -86,115 +573,131 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#3b83f6",
fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
{component.label}
{component.required && (
<span
style={{
color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
*
</span>
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)}
<div
style={{
width: "100%",
height: "100%",
border: "2px dashed #d1d5db",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
backgroundColor: "#f9fafb",
position: "relative",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
<div className="w-full h-full flex flex-col space-y-2">
{/* 디자인 모드가 아닐 때만 파일 업로드 영역 표시 */}
{!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
`}
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<input
ref={fileInputRef}
type="file"
multiple={componentConfig.multiple || false}
accept={componentConfig.accept || "*/*"}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: 0,
cursor: "pointer",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onChange={(e) => {
if (component.onChange) {
const files = Array.from(e.target.files || []);
component.onChange(componentConfig.multiple ? files : files[0]);
}
}}
/>
<div
style={{
textAlign: "center",
color: "#6b7280",
fontSize: "14px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
<div
style={{
fontSize: "24px",
marginBottom: "8px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
📁
multiple={safeComponentConfig.multiple}
accept={safeComponentConfig.accept}
onChange={handleInputChange}
className="hidden"
disabled={safeComponentConfig.disabled}
/>
{uploadStatus === 'uploading' ? (
<div className="flex flex-col items-center space-y-2">
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="text-blue-600 font-medium"> ...</span>
</div>
</div>
) : (
<>
<div>
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-900 mb-2">
{safeComponentConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요"}
</p>
<p className="text-xs text-gray-500 mt-1">
{safeComponentConfig.accept && `지원 형식: ${safeComponentConfig.accept}`}
{safeComponentConfig.maxSize && ` • 최대 ${formatFileSize(safeComponentConfig.maxSize)}`}
{safeComponentConfig.multiple && ' • 여러 파일 선택 가능'}
</p>
</div>
</>
)}
</div>
<div
style={{
fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
)}
{/* 업로드된 파일 목록 - 디자인 모드에서는 항상 표시 */}
{(uploadedFiles.length > 0 || isDesignMode) && (
<div className="flex-1 overflow-y-auto">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700">
({uploadedFiles.length})
</h4>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
{uploadedFiles.length > 0 ? (
<div className="space-y-1">
{uploadedFiles.map((file) => (
<div key={file.objid} className="flex items-center space-x-2 p-2 bg-gray-50 rounded text-sm">
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<span className="flex-1 truncate text-gray-900">
{file.realFileName}
</span>
<span className="text-xs text-gray-500">
{formatFileSize(file.fileSize)}
</span>
</div>
))}
<div className="text-xs text-gray-500 mt-2 text-center">
💡
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<File className="w-12 h-12 mb-3 text-gray-300" />
<p className="text-sm font-medium"> </p>
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
)}
</div>
<div
style={{
fontSize: "12px",
marginTop: "4px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
>
{componentConfig.accept && `지원 형식: ${componentConfig.accept}`}
</div>
</div>
)}
{/* 도움말 텍스트 */}
{safeComponentConfig.helperText && (
<p className="text-xs text-gray-500 mt-1">
{safeComponentConfig.helperText}
</p>
)}
</div>
{/* 파일뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
/>
</div>
);
};
/**
* FileUpload
*
*/
export const FileUploadWrapper: React.FC<FileUploadComponentProps> = (props) => {
return <FileUploadComponent {...props} />;
};
export { FileUploadComponent };
export default FileUploadComponent;

View File

@ -0,0 +1,310 @@
"use client";
import React, { useState, useEffect } 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 } from "./types";
import { Download, X, AlertTriangle, FileText, Image as ImageIcon } from "lucide-react";
import { formatFileSize } from "@/lib/utils";
interface FileViewerModalProps {
file: FileInfo | null;
isOpen: boolean;
onClose: () => void;
onDownload?: (file: FileInfo) => void;
}
/**
*
*
*/
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
file,
isOpen,
onClose,
onDownload,
}) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// 파일이 변경될 때마다 미리보기 URL 생성
useEffect(() => {
if (!file || !isOpen) {
setPreviewUrl(null);
setPreviewError(null);
return;
}
setIsLoading(true);
setPreviewError(null);
// 로컬 파일인 경우
if (file._file) {
const url = URL.createObjectURL(file._file);
setPreviewUrl(url);
setIsLoading(false);
return () => URL.revokeObjectURL(url);
}
// 서버 파일인 경우 - 미리보기 API 호출
const generatePreviewUrl = async () => {
try {
const fileExt = file.fileExt.toLowerCase();
// 미리보기 지원 파일 타입 정의
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'odt', 'ods', 'odp', 'hwp', 'hwpx', 'hwpml', 'hcdt', 'hpt', 'pages', 'numbers', 'keynote'];
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv'];
const mediaExtensions = ['mp4', 'webm', 'ogg', 'mp3', 'wav'];
const supportedExtensions = [
...imageExtensions,
...documentExtensions,
...textExtensions,
...mediaExtensions
];
if (supportedExtensions.includes(fileExt)) {
// 실제 환경에서는 파일 서빙 API 엔드포인트 사용
const url = `/api/files/preview/${file.objid}`;
setPreviewUrl(url);
} else {
// 지원하지 않는 파일 타입
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
}
} catch (error) {
console.error('미리보기 URL 생성 오류:', error);
setPreviewError('미리보기를 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
generatePreviewUrl();
}, [file, isOpen]);
if (!file) return null;
// 파일 타입별 미리보기 컴포넌트
const renderPreview = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (previewError) {
return (
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
<AlertTriangle className="w-16 h-16 mb-4" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center">{previewError}</p>
<Button
variant="outline"
onClick={() => onDownload?.(file)}
className="mt-4"
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
);
}
const fileExt = file.fileExt.toLowerCase();
// 이미지 파일
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(fileExt)) {
return (
<div className="flex items-center justify-center max-h-96 overflow-hidden">
<img
src={previewUrl || ''}
alt={file.realFileName}
className="max-w-full max-h-full object-contain rounded-lg"
onError={() => setPreviewError('이미지를 불러올 수 없습니다.')}
/>
</div>
);
}
// 텍스트 파일
if (['txt', 'md', 'json', 'xml', 'csv'].includes(fileExt)) {
return (
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ''}
className="w-full h-full border rounded-lg"
title={`${file.realFileName} 미리보기`}
onError={() => setPreviewError('텍스트 파일을 불러올 수 없습니다.')}
/>
</div>
);
}
// PDF 파일
if (fileExt === 'pdf') {
return (
<div className="h-96">
<iframe
src={previewUrl || ''}
className="w-full h-full border rounded-lg"
title={`${file.realFileName} 미리보기`}
onError={() => setPreviewError('PDF 파일을 불러올 수 없습니다.')}
/>
</div>
);
}
// Microsoft Office, 한컴오피스, Apple iWork 문서 파일
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'hwpml', 'hcdt', 'hpt', 'pages', 'numbers', 'keynote'].includes(fileExt)) {
// Office 파일은 Google Docs Viewer 또는 Microsoft Office Online을 통해 미리보기
const officeViewerUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(previewUrl || '')}`;
return (
<div className="h-96">
<iframe
src={officeViewerUrl}
className="w-full h-full border rounded-lg"
title={`${file.realFileName} 미리보기`}
onError={() => setPreviewError('Office 문서를 불러올 수 없습니다. 파일을 다운로드하여 확인해주세요.')}
/>
</div>
);
}
// 기타 문서 파일 (RTF, ODT 등)
if (['rtf', 'odt', 'ods', 'odp'].includes(fileExt)) {
return (
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
<FileText className="w-16 h-16 mb-4 text-blue-500" />
<p className="text-lg font-medium mb-2">{file.fileExt.toUpperCase()} </p>
<p className="text-sm text-center mb-4">
{file.realFileName}
</p>
<div className="flex flex-col items-center space-y-2 text-xs text-gray-400">
<p> : {formatFileSize(file.fileSize)}</p>
<p> : {file.docTypeName || '일반 문서'}</p>
</div>
<Button
variant="outline"
onClick={() => onDownload?.(file)}
className="mt-4"
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
);
}
// 비디오 파일
if (['mp4', 'webm', 'ogg'].includes(fileExt)) {
return (
<div className="flex items-center justify-center">
<video
controls
className="max-w-full max-h-96 rounded-lg"
onError={() => setPreviewError('비디오를 재생할 수 없습니다.')}
>
<source src={previewUrl || ''} type={`video/${fileExt}`} />
.
</video>
</div>
);
}
// 오디오 파일
if (['mp3', 'wav', 'ogg'].includes(fileExt)) {
return (
<div className="flex flex-col items-center justify-center h-96">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6">
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
<audio
controls
className="w-full max-w-md"
onError={() => setPreviewError('오디오를 재생할 수 없습니다.')}
>
<source src={previewUrl || ''} type={`audio/${fileExt}`} />
.
</audio>
</div>
);
}
// 기타 파일 타입
return (
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
<FileText className="w-16 h-16 mb-4" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center mb-4">
{file.fileExt.toUpperCase()} .
</p>
<Button
variant="outline"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<DialogTitle className="text-lg font-semibold truncate">
{file.realFileName}
</DialogTitle>
<Badge variant="secondary" className="text-xs">
{file.fileExt.toUpperCase()}
</Badge>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClose}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* 파일 정보 */}
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
<span>: {formatFileSize(file.fileSize)}</span>
{file.uploadedAt && (
<span>: {new Date(file.uploadedAt).toLocaleString()}</span>
)}
{file.writer && <span>: {file.writer}</span>}
</div>
</DialogHeader>
{/* 파일 미리보기 영역 */}
<div className="flex-1 overflow-auto py-4">
{renderPreview()}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { FileUploadWrapper } from "./FileUploadComponent";
import { FileUploadComponent } from "./FileUploadComponent";
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
import { FileUploadConfig } from "./types";
@ -19,7 +19,7 @@ export const FileUploadDefinition = createComponentDefinition({
description: "파일 업로드를 위한 파일 선택 컴포넌트",
category: ComponentCategory.INPUT,
webType: "file",
component: FileUploadWrapper,
component: FileUploadComponent,
defaultConfig: {
placeholder: "입력하세요",
},

View File

@ -2,18 +2,50 @@
import { ComponentConfig } from "@/types/component";
/**
* (AttachedFileInfo와 )
*/
export interface FileInfo {
// AttachedFileInfo 기본 속성들
objid: string;
savedFileName: string;
realFileName: string;
fileSize: number;
fileExt: string;
filePath: string;
docType: string;
docTypeName: string;
targetObjid: string;
parentTargetObjid?: string;
companyCode: string;
writer: string;
regdate: string;
status: string;
// 추가 호환성 속성들
path?: string; // filePath와 동일
name?: string; // realFileName과 동일
id?: string; // objid와 동일
size?: number; // fileSize와 동일
type?: string; // docType과 동일
uploadedAt?: string; // regdate와 동일
_file?: File; // 로컬 파일 객체 (업로드 전)
}
/**
* FileUpload
*/
export interface FileUploadConfig extends ComponentConfig {
// file 관련 설정
// file 관련 설정
placeholder?: string;
multiple?: boolean;
accept?: string;
maxSize?: number; // bytes
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 스타일 관련
@ -25,6 +57,9 @@ export interface FileUploadConfig extends ComponentConfig {
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
@ -38,9 +73,30 @@ export interface FileUploadProps {
className?: string;
style?: React.CSSProperties;
// 파일 관련
uploadedFiles?: FileInfo[];
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
onFileUpload?: (files: FileInfo[]) => void;
onFileDelete?: (fileId: string) => void;
onFileDownload?: (file: FileInfo) => void;
}
/**
*
*/
export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
/**
*
*/
export interface FileUploadResponse {
success: boolean;
data?: FileInfo[];
message?: string;
error?: string;
}

View File

@ -4,3 +4,14 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
*
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}