문서뷰어기능구현
This commit is contained in:
parent
3600621554
commit
e0143e9cba
|
|
@ -44,7 +44,14 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
|||
const app = express();
|
||||
|
||||
// 기본 미들웨어
|
||||
app.use(helmet());
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||
"frame-ancestors": ["'self'", "http://localhost:9771", "http://localhost:3000"], // 프론트엔드 도메인 허용
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { generateUUID } from "../utils/generateId";
|
|||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
||||
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
||||
|
||||
// 업로드 디렉토리 설정 (회사별로 분리)
|
||||
const baseUploadDir = path.join(process.cwd(), "uploads");
|
||||
|
||||
|
|
@ -266,9 +269,7 @@ export const uploadFiles = async (
|
|||
|
||||
// 회사코드가 *인 경우 company_*로 변환
|
||||
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
|
||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
|
||||
// 임시 파일을 최종 위치로 이동
|
||||
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
||||
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
|
||||
|
|
@ -277,6 +278,10 @@ export const uploadFiles = async (
|
|||
// 파일 이동
|
||||
fs.renameSync(tempFilePath, finalFilePath);
|
||||
|
||||
// DB에 저장할 경로 (실제 파일 위치와 일치)
|
||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
// attach_file_info 테이블에 저장
|
||||
const fileRecord = await prisma.attach_file_info.create({
|
||||
data: {
|
||||
|
|
@ -485,6 +490,133 @@ export const getFileList = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 템플릿 파일과 데이터 파일을 모두 조회
|
||||
*/
|
||||
export const getComponentFiles = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId, componentId, tableName, recordId, columnName } = req.query;
|
||||
|
||||
console.log("📂 [getComponentFiles] API 호출:", {
|
||||
screenId,
|
||||
componentId,
|
||||
tableName,
|
||||
recordId,
|
||||
columnName,
|
||||
user: req.user?.userId
|
||||
});
|
||||
|
||||
if (!screenId || !componentId) {
|
||||
console.log("❌ [getComponentFiles] 필수 파라미터 누락");
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId와 componentId가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
|
||||
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || 'field_1'}`;
|
||||
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid });
|
||||
|
||||
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
|
||||
const allFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
},
|
||||
select: {
|
||||
target_objid: true,
|
||||
real_file_name: true,
|
||||
regdate: true,
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name })));
|
||||
|
||||
const templateFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: templateTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length);
|
||||
|
||||
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
||||
let dataFiles: any[] = [];
|
||||
if (tableName && recordId && columnName) {
|
||||
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
dataFiles = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
target_objid: dataTargetObjid,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
orderBy: {
|
||||
regdate: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 정보 포맷팅 함수
|
||||
const formatFileInfo = (file: any, isTemplate: boolean = false) => ({
|
||||
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,
|
||||
isTemplate, // 템플릿 파일 여부 표시
|
||||
});
|
||||
|
||||
const formattedTemplateFiles = templateFiles.map(file => formatFileInfo(file, true));
|
||||
const formattedDataFiles = dataFiles.map(file => formatFileInfo(file, false));
|
||||
|
||||
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
|
||||
const totalFiles = formattedDataFiles.length > 0
|
||||
? formattedDataFiles
|
||||
: formattedTemplateFiles;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
templateFiles: formattedTemplateFiles,
|
||||
dataFiles: formattedDataFiles,
|
||||
totalFiles,
|
||||
summary: {
|
||||
templateCount: formattedTemplateFiles.length,
|
||||
dataCount: formattedDataFiles.length,
|
||||
totalCount: totalFiles.length,
|
||||
templateTargetObjid,
|
||||
dataTargetObjid: tableName && recordId && columnName
|
||||
? `${tableName}:${recordId}:${columnName}`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("컴포넌트 파일 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "컴포넌트 파일 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 미리보기 (이미지 등)
|
||||
*/
|
||||
|
|
@ -512,7 +644,13 @@ export const previewFile = async (
|
|||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
const companyCode = filePathParts[2] || "DEFAULT";
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
|
|
@ -527,6 +665,17 @@ export const previewFile = async (
|
|||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("🔍 파일 미리보기 경로 확인:", {
|
||||
objid: objid,
|
||||
filePathFromDB: fileRecord.file_path,
|
||||
companyCode: companyCode,
|
||||
dateFolder: dateFolder,
|
||||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
|
|
@ -615,7 +764,13 @@ export const downloadFile = async (
|
|||
|
||||
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
const companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
|
||||
|
||||
// company_* 처리 (실제 회사 코드로 변환)
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
|
||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||
|
|
@ -631,6 +786,17 @@ export const downloadFile = async (
|
|||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("🔍 파일 다운로드 경로 확인:", {
|
||||
objid: objid,
|
||||
filePathFromDB: fileRecord.file_path,
|
||||
companyCode: companyCode,
|
||||
dateFolder: dateFolder,
|
||||
fileName: fileName,
|
||||
companyUploadDir: companyUploadDir,
|
||||
finalFilePath: filePath,
|
||||
fileExists: fs.existsSync(filePath)
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
|
|
@ -660,5 +826,178 @@ export const downloadFile = async (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Google Docs Viewer용 임시 공개 토큰 생성
|
||||
*/
|
||||
export const generateTempToken = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
if (!objid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "파일 ID가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 존재 확인
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 임시 토큰 생성 (30분 유효)
|
||||
const token = generateUUID();
|
||||
const expires = Date.now() + 30 * 60 * 1000; // 30분
|
||||
|
||||
tempTokens.set(token, {
|
||||
objid: objid,
|
||||
expires: expires,
|
||||
});
|
||||
|
||||
// 만료된 토큰 정리 (메모리 누수 방지)
|
||||
const now = Date.now();
|
||||
for (const [key, value] of tempTokens.entries()) {
|
||||
if (value.expires < now) {
|
||||
tempTokens.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: token,
|
||||
publicUrl: `${req.protocol}://${req.get("host")}/api/files/public/${token}`,
|
||||
expires: new Date(expires).toISOString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 임시 토큰 생성 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "임시 토큰 생성에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 임시 토큰으로 파일 접근 (인증 불필요)
|
||||
*/
|
||||
export const getFileByToken = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "토큰이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰 확인
|
||||
const tokenData = tempTokens.get(token);
|
||||
if (!tokenData) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 토큰입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰 만료 확인
|
||||
if (tokenData.expires < Date.now()) {
|
||||
tempTokens.delete(token);
|
||||
res.status(410).json({
|
||||
success: false,
|
||||
message: "토큰이 만료되었습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 정보 조회
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: { objid: tokenData.objid },
|
||||
});
|
||||
|
||||
if (!fileRecord) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 경로 구성
|
||||
const filePathParts = fileRecord.file_path!.split("/");
|
||||
let companyCode = filePathParts[2] || "DEFAULT";
|
||||
if (companyCode === "company_*") {
|
||||
companyCode = "company_*"; // 실제 디렉토리명 유지
|
||||
}
|
||||
const fileName = fileRecord.saved_file_name!;
|
||||
let dateFolder = "";
|
||||
if (filePathParts.length >= 6) {
|
||||
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
|
||||
}
|
||||
const companyUploadDir = getCompanyUploadDir(companyCode, dateFolder || undefined);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
// 파일 존재 확인
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "실제 파일을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// MIME 타입 설정
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
let contentType = "application/octet-stream";
|
||||
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".txt": "text/plain",
|
||||
};
|
||||
|
||||
if (mimeTypes[ext]) {
|
||||
contentType = mimeTypes[ext];
|
||||
}
|
||||
|
||||
// 파일 헤더 설정
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`);
|
||||
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
|
||||
|
||||
// 파일 스트림 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error("❌ 토큰 파일 접근 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "파일 접근에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Multer 미들웨어 export
|
||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||
|
|
|
|||
|
|
@ -3,15 +3,26 @@ import {
|
|||
uploadFiles,
|
||||
deleteFile,
|
||||
getFileList,
|
||||
getComponentFiles,
|
||||
downloadFile,
|
||||
previewFile,
|
||||
getLinkedFiles,
|
||||
uploadMiddleware,
|
||||
generateTempToken,
|
||||
getFileByToken,
|
||||
} from "../controllers/fileController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 공개 접근 라우트 (인증 불필요)
|
||||
/**
|
||||
* @route GET /api/files/public/:token
|
||||
* @desc 임시 토큰으로 파일 접근 (Google Docs Viewer용)
|
||||
* @access Public
|
||||
*/
|
||||
router.get("/public/:token", getFileByToken);
|
||||
|
||||
// 모든 파일 API는 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
|
|
@ -30,6 +41,14 @@ router.post("/upload", uploadMiddleware, uploadFiles);
|
|||
*/
|
||||
router.get("/", getFileList);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/component-files
|
||||
* @desc 컴포넌트의 템플릿 파일과 데이터 파일 모두 조회
|
||||
* @query screenId, componentId, tableName, recordId, columnName
|
||||
* @access Private
|
||||
*/
|
||||
router.get("/component-files", getComponentFiles);
|
||||
|
||||
/**
|
||||
* @route GET /api/files/linked/:tableName/:recordId
|
||||
* @desc 테이블 연결된 파일 조회
|
||||
|
|
@ -58,4 +77,11 @@ router.get("/preview/:objid", previewFile);
|
|||
*/
|
||||
router.get("/download/:objid", downloadFile);
|
||||
|
||||
/**
|
||||
* @route POST /api/files/temp-token/:objid
|
||||
* @desc Google Docs Viewer용 임시 공개 토큰 생성
|
||||
* @access Private
|
||||
*/
|
||||
router.post("/temp-token/:objid", generateTempToken);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||
|
||||
/**
|
||||
* 관리자 메인 페이지
|
||||
|
|
@ -199,6 +200,16 @@ export default function AdminPage() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전역 파일 관리 */}
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">전역 파일 관리</h2>
|
||||
<p className="text-gray-600">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
||||
</div>
|
||||
<GlobalFileViewer />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
|
|||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
||||
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
|
|
@ -324,7 +325,19 @@ export default function ScreenViewPage() {
|
|||
/>
|
||||
) : (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={component.webType || "text"}
|
||||
webType={(() => {
|
||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||
if (isFileComponent(component)) {
|
||||
console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
originalWebType: component.webType
|
||||
});
|
||||
return "file";
|
||||
}
|
||||
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
|
||||
return getComponentWebType(component) || "text";
|
||||
})()}
|
||||
config={component.webTypeConfig}
|
||||
props={{
|
||||
component: component,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { GlobalFileManager, GlobalFileInfo } from "@/lib/api/globalFile";
|
||||
import { downloadFile } from "@/lib/api/file";
|
||||
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Video,
|
||||
Music,
|
||||
Archive,
|
||||
Download,
|
||||
Eye,
|
||||
Search,
|
||||
Trash2,
|
||||
Clock,
|
||||
MapPin,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
interface GlobalFileViewerProps {
|
||||
showControls?: boolean;
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||
showControls = true,
|
||||
maxHeight = "600px",
|
||||
className = "",
|
||||
}) => {
|
||||
const [allFiles, setAllFiles] = useState<GlobalFileInfo[]>([]);
|
||||
const [filteredFiles, setFilteredFiles] = useState<GlobalFileInfo[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTab, setSelectedTab] = useState("all");
|
||||
const [viewerFile, setViewerFile] = useState<GlobalFileInfo | null>(null);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [registryInfo, setRegistryInfo] = useState({
|
||||
totalFiles: 0,
|
||||
accessibleFiles: 0,
|
||||
pages: [] as string[],
|
||||
screens: [] as number[],
|
||||
});
|
||||
|
||||
// 파일 아이콘 가져오기
|
||||
const getFileIcon = (fileName: string, size: number = 16) => {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const iconProps = { size, className: "text-gray-600" };
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) {
|
||||
return <Image {...iconProps} className="text-blue-600" />;
|
||||
}
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) {
|
||||
return <Video {...iconProps} className="text-purple-600" />;
|
||||
}
|
||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) {
|
||||
return <Music {...iconProps} className="text-green-600" />;
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
|
||||
return <Archive {...iconProps} className="text-yellow-600" />;
|
||||
}
|
||||
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
|
||||
return <FileText {...iconProps} className="text-red-600" />;
|
||||
}
|
||||
return <File {...iconProps} />;
|
||||
};
|
||||
|
||||
// 파일 목록 새로고침
|
||||
const refreshFiles = () => {
|
||||
const files = GlobalFileManager.getAllAccessibleFiles();
|
||||
const info = GlobalFileManager.getRegistryInfo();
|
||||
|
||||
setAllFiles(files);
|
||||
setRegistryInfo(info);
|
||||
|
||||
// 탭에 따른 필터링
|
||||
filterFilesByTab(files, selectedTab, searchQuery);
|
||||
|
||||
console.log("🔄 전역 파일 목록 새로고침:", files.length + "개");
|
||||
};
|
||||
|
||||
// 탭별 파일 필터링
|
||||
const filterFilesByTab = (files: GlobalFileInfo[], tab: string, query: string) => {
|
||||
let filtered = files;
|
||||
|
||||
// 탭별 필터링
|
||||
if (tab === "images") {
|
||||
filtered = files.filter(file => {
|
||||
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
|
||||
});
|
||||
} else if (tab === "documents") {
|
||||
filtered = files.filter(file => {
|
||||
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
|
||||
return ['txt', 'md', 'doc', 'docx', 'pdf', 'rtf', 'hwp', 'hwpx'].includes(ext);
|
||||
});
|
||||
} else if (tab === "recent") {
|
||||
filtered = files
|
||||
.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime())
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
// 검색 필터링
|
||||
if (query.trim()) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
filtered = filtered.filter(file =>
|
||||
file.realFileName?.toLowerCase().includes(lowerQuery) ||
|
||||
file.savedFileName?.toLowerCase().includes(lowerQuery) ||
|
||||
file.uploadPage?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredFiles(filtered);
|
||||
};
|
||||
|
||||
// 파일 다운로드
|
||||
const handleDownload = async (file: GlobalFileInfo) => {
|
||||
try {
|
||||
await downloadFile({
|
||||
fileId: file.objid,
|
||||
originalName: file.realFileName || file.savedFileName || "download",
|
||||
});
|
||||
toast.success(`파일 다운로드 시작: ${file.realFileName}`);
|
||||
} catch (error) {
|
||||
console.error("파일 다운로드 오류:", error);
|
||||
toast.error("파일 다운로드에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 뷰어 열기
|
||||
const handleView = (file: GlobalFileInfo) => {
|
||||
setViewerFile(file);
|
||||
setIsViewerOpen(true);
|
||||
};
|
||||
|
||||
// 파일 접근 불가능하게 설정 (삭제 대신)
|
||||
const handleRemove = (file: GlobalFileInfo) => {
|
||||
GlobalFileManager.setFileAccessible(file.objid, false);
|
||||
refreshFiles();
|
||||
toast.success(`파일이 목록에서 제거되었습니다: ${file.realFileName}`);
|
||||
};
|
||||
|
||||
// 초기 로드 및 검색/탭 변경 시 필터링
|
||||
useEffect(() => {
|
||||
refreshFiles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterFilesByTab(allFiles, selectedTab, searchQuery);
|
||||
}, [allFiles, selectedTab, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<File className="w-5 h-5" />
|
||||
전역 파일 저장소
|
||||
</CardTitle>
|
||||
{showControls && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
{registryInfo.accessibleFiles}개 파일
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshFiles}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showControls && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="파일명으로 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all">전체</TabsTrigger>
|
||||
<TabsTrigger value="recent">최근</TabsTrigger>
|
||||
<TabsTrigger value="images">이미지</TabsTrigger>
|
||||
<TabsTrigger value="documents">문서</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={selectedTab} className="mt-4">
|
||||
<div
|
||||
className="space-y-2 overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
filteredFiles.map((file) => (
|
||||
<Card key={file.objid} className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{getFileIcon(file.realFileName || file.savedFileName || "", 20)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{file.realFileName || file.savedFileName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<span>{formatFileSize(file.fileSize)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(file.uploadTime).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{file.uploadPage.split('/').pop() || 'Unknown'}
|
||||
</div>
|
||||
{file.screenId && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Screen {file.screenId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(file)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(file)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(file)}
|
||||
className="flex items-center gap-1 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 파일 뷰어 모달 */}
|
||||
{viewerFile && (
|
||||
<FileViewerModal
|
||||
file={viewerFile}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={() => {
|
||||
setIsViewerOpen(false);
|
||||
setViewerFile(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -42,6 +42,7 @@ import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
|||
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||
import { useFormValidation } from "@/hooks/useFormValidation";
|
||||
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
interface InteractiveScreenViewerProps {
|
||||
component: ComponentData;
|
||||
|
|
@ -771,11 +772,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
const currentValue = getCurrentValue();
|
||||
|
||||
// 화면 ID 추출 (URL에서)
|
||||
const screenId = typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
? parseInt(window.location.pathname.split('/screens/')[1])
|
||||
: null;
|
||||
|
||||
console.log("📁 InteractiveScreenViewer - File 위젯:", {
|
||||
componentId: widget.id,
|
||||
widgetType: widget.widgetType,
|
||||
config,
|
||||
currentValue,
|
||||
screenId,
|
||||
appliedSettings: {
|
||||
accept: config?.accept,
|
||||
multiple: config?.multiple,
|
||||
|
|
@ -1572,7 +1579,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
};
|
||||
|
||||
// 파일 첨부 컴포넌트 처리
|
||||
if (component.type === "file") {
|
||||
if (isFileComponent(component)) {
|
||||
const fileComponent = component as FileComponent;
|
||||
|
||||
console.log("🎯 File 컴포넌트 렌더링:", {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable";
|
|||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
@ -143,7 +144,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// 동적 대화형 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (comp.type === "datatable") {
|
||||
if (isDataTableComponent(comp)) {
|
||||
return (
|
||||
<InteractiveDataTable
|
||||
component={comp as DataTableComponent}
|
||||
|
|
@ -157,12 +158,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}
|
||||
|
||||
// 버튼 컴포넌트 처리
|
||||
if (comp.type === "button") {
|
||||
if (isButtonComponent(comp)) {
|
||||
return renderButton(comp);
|
||||
}
|
||||
|
||||
// 파일 컴포넌트 처리
|
||||
if (comp.type === "file") {
|
||||
if (isFileComponent(comp)) {
|
||||
return renderFileComponent(comp as FileComponent);
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +414,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const { label, readonly } = comp;
|
||||
const fieldName = comp.columnName || comp.id;
|
||||
|
||||
// 화면 ID 추출 (URL에서)
|
||||
const screenId = screenInfo?.screenId ||
|
||||
(typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
? parseInt(window.location.pathname.split('/screens/')[1])
|
||||
: null);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{/* 실제 FileUploadComponent 사용 */}
|
||||
|
|
@ -433,6 +440,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
isInteractive={true}
|
||||
isDesignMode={false}
|
||||
formData={{
|
||||
screenId, // 화면 ID 전달
|
||||
tableName: screenInfo?.tableName,
|
||||
id: formData.id,
|
||||
...formData
|
||||
|
|
@ -447,7 +455,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}}
|
||||
onUpdate={(updates) => {
|
||||
console.log("🔄 파일 컴포넌트 업데이트:", updates);
|
||||
// 파일 업로드 완료 시 formData 업데이트
|
||||
// 파일 업로드 완료 시 formData 업데이터
|
||||
if (updates.uploadedFiles && onFormDataChange) {
|
||||
onFormDataChange(fieldName, updates.uploadedFiles);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -126,6 +127,33 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
|||
className: `w-full h-full ${borderClass}`,
|
||||
};
|
||||
|
||||
// 파일 컴포넌트 강제 체크
|
||||
if (isFileComponent(widget)) {
|
||||
console.log("🎯 RealtimePreview - 파일 컴포넌트 강제 감지:", {
|
||||
componentId: widget.id,
|
||||
widgetType: widgetType,
|
||||
isFileComponent: true
|
||||
});
|
||||
|
||||
try {
|
||||
return (
|
||||
<DynamicWebTypeRenderer
|
||||
webType="file"
|
||||
props={{
|
||||
...commonProps,
|
||||
component: widget,
|
||||
value: undefined, // 미리보기이므로 값은 없음
|
||||
readonly: readonly,
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`파일 웹타입 렌더링 실패:`, error);
|
||||
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (렌더링 오류)</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
if (widgetType) {
|
||||
try {
|
||||
|
|
@ -209,6 +237,29 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { type, id, position, size, style = {} } = component;
|
||||
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
|
||||
|
||||
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
if (event.detail.componentId === component.id) {
|
||||
console.log("🔄 RealtimePreview 파일 상태 변경 감지:", {
|
||||
componentId: component.id,
|
||||
filesCount: event.detail.files?.length || 0,
|
||||
action: event.detail.action
|
||||
});
|
||||
setFileUpdateTrigger(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, [component.id]);
|
||||
|
||||
// 컴포넌트 스타일 계산
|
||||
const componentStyle = {
|
||||
|
|
@ -308,8 +359,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 타입 */}
|
||||
{type === "file" && (() => {
|
||||
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
||||
{isFileComponent(component) && (() => {
|
||||
const fileComponent = component as any;
|
||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||
|
||||
|
|
@ -327,6 +378,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
currentFilesCount: currentFiles.length,
|
||||
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
||||
componentType: component.type,
|
||||
fileUpdateTrigger: fileUpdateTrigger,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -734,6 +734,72 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
initComponents();
|
||||
}, []);
|
||||
|
||||
// 화면 선택 시 파일 복원
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.screenId) {
|
||||
restoreScreenFiles();
|
||||
}
|
||||
}, [selectedScreen?.screenId]);
|
||||
|
||||
// 화면의 모든 파일 컴포넌트 파일 복원
|
||||
const restoreScreenFiles = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
try {
|
||||
console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
|
||||
|
||||
// 해당 화면의 모든 파일 조회
|
||||
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||
|
||||
if (response.success && response.componentFiles) {
|
||||
console.log("📁 복원할 파일 데이터:", response.componentFiles);
|
||||
|
||||
// 각 컴포넌트별로 파일 복원
|
||||
Object.entries(response.componentFiles).forEach(([componentId, files]) => {
|
||||
if (Array.isArray(files) && files.length > 0) {
|
||||
// 전역 상태에 파일 저장
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
globalFileState[componentId] = files;
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).globalFileState = globalFileState;
|
||||
}
|
||||
|
||||
// localStorage에도 백업
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(files));
|
||||
}
|
||||
|
||||
console.log(`📂 컴포넌트 ${componentId} 파일 복원:`, files.length, "개");
|
||||
}
|
||||
});
|
||||
|
||||
// 레이아웃의 컴포넌트들에 파일 정보 적용
|
||||
setLayout(prevLayout => {
|
||||
const updatedComponents = prevLayout.components.map(comp => {
|
||||
const componentFiles = response.componentFiles[comp.id];
|
||||
if (componentFiles && componentFiles.length > 0) {
|
||||
return {
|
||||
...comp,
|
||||
uploadedFiles: componentFiles,
|
||||
lastFileUpdate: Date.now()
|
||||
};
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
components: updatedComponents
|
||||
};
|
||||
});
|
||||
|
||||
console.log("✅ 화면 파일 복원 완료");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 파일 복원 오류:", error);
|
||||
}
|
||||
}, [selectedScreen?.screenId]);
|
||||
|
||||
// 전역 파일 상태 변경 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function ColumnComponent({
|
|||
className={cn(
|
||||
"flex-1 rounded border border-gray-200 p-2",
|
||||
isSelected && "border-blue-500 bg-blue-50",
|
||||
isMoving && "cursor-move shadow-lg",
|
||||
isMoving && "cursor-move",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function ContainerComponent({
|
|||
className={cn(
|
||||
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
|
||||
isSelected && "border-blue-500 bg-blue-50",
|
||||
isMoving && "cursor-move shadow-lg",
|
||||
isMoving && "cursor-move",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export default function RowComponent({
|
|||
className={cn(
|
||||
"flex gap-4 rounded border border-gray-200 p-2",
|
||||
isSelected && "border-blue-500 bg-blue-50",
|
||||
isMoving && "cursor-move shadow-lg",
|
||||
isMoving && "cursor-move",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "@/types/screen";
|
||||
// 레거시 ButtonConfigPanel 제거됨
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 새로운 컴포넌트 설정 패널들 import
|
||||
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||
|
|
@ -908,7 +909,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
|
||||
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
|
||||
if (selectedComponent.type === "file" || (selectedComponent.type === "widget" && selectedComponent.widgetType === "file")) {
|
||||
if (isFileComponent(selectedComponent)) {
|
||||
const fileComponent = selectedComponent as FileComponent;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { FileComponent, TableInfo } from "@/types/screen";
|
||||
import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileInfo } from "@/lib/registry/components/file-upload/types";
|
||||
import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types";
|
||||
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -302,7 +302,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
files: filesToUpload,
|
||||
tableName: tableName,
|
||||
fieldName: fieldName,
|
||||
recordId: `${screenId}:${componentId}`, // 화면ID:컴포넌트ID 형태
|
||||
recordId: `screen_${screenId}:${componentId}`, // 템플릿 파일 형태
|
||||
docType: localInputs.docType,
|
||||
docTypeName: localInputs.docTypeName,
|
||||
targetObjid: targetObjid, // 그리드 연동을 위한 targetObjid
|
||||
|
|
@ -358,6 +358,19 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
const backupKey = `fileComponent_${component.id}_files`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
|
||||
// 전역 파일 상태 변경 이벤트 발생 (RealtimePreview 업데이트용)
|
||||
if (typeof window !== 'undefined') {
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
files: updatedFiles,
|
||||
action: 'upload',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
console.log("🔄 FileComponentConfigPanel 자동 저장:", {
|
||||
componentId: component.id,
|
||||
uploadedFiles: updatedFiles.length,
|
||||
|
|
@ -369,6 +382,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== 'undefined') {
|
||||
const tableName = component.tableName || currentTableName || 'unknown';
|
||||
const columnName = component.columnName || component.id;
|
||||
const recordId = component.id; // 임시로 컴포넌트 ID 사용
|
||||
const targetObjid = component.id;
|
||||
|
||||
const refreshEvent = new CustomEvent('refreshFileStatus', {
|
||||
detail: {
|
||||
tableName: tableName,
|
||||
|
|
@ -413,7 +431,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
||||
try {
|
||||
await downloadFile({
|
||||
fileId: file.objid || file.id,
|
||||
fileId: file.objid || file.id || '',
|
||||
serverFilename: file.savedFileName,
|
||||
originalName: file.realFileName || file.name || 'download',
|
||||
});
|
||||
|
|
@ -427,7 +445,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
// 파일 삭제 처리
|
||||
const handleFileDelete = useCallback(async (fileId: string) => {
|
||||
try {
|
||||
await deleteFile(fileId);
|
||||
await deleteFile(fileId, 'temp_record');
|
||||
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
|
||||
setUploadedFiles(updatedFiles);
|
||||
|
||||
|
|
@ -959,7 +977,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileDelete(file.objid || file.id)}
|
||||
onClick={() => handleFileDelete(file.objid || file.id || '')}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
title="삭제"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface FileUploadResponse {
|
|||
success: boolean;
|
||||
message: string;
|
||||
files: FileInfo[];
|
||||
data?: FileInfo[];
|
||||
}
|
||||
|
||||
export interface FileDownloadParams {
|
||||
|
|
@ -134,6 +135,28 @@ export const getFileInfo = async (fileId: string, serverFilename: string) => {
|
|||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 템플릿 파일과 데이터 파일을 모두 조회
|
||||
*/
|
||||
export const getComponentFiles = async (params: {
|
||||
screenId: number;
|
||||
componentId: string;
|
||||
tableName?: string;
|
||||
recordId?: string;
|
||||
columnName?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
templateFiles: FileInfo[];
|
||||
dataFiles: FileInfo[];
|
||||
totalFiles: FileInfo[];
|
||||
}> => {
|
||||
const response = await apiClient.get('/files/component-files', {
|
||||
params,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 업로드 및 JSON 데이터 생성
|
||||
* InteractiveScreenViewer에서 사용할 통합 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
import { FileInfo } from "./file";
|
||||
|
||||
export interface GlobalFileInfo extends FileInfo {
|
||||
uploadPage: string;
|
||||
uploadTime: string;
|
||||
componentId: string;
|
||||
screenId?: number;
|
||||
accessible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 파일 저장소 관리 클래스
|
||||
* 페이지 간 파일 공유를 위한 클라이언트 사이드 파일 레지스트리
|
||||
*/
|
||||
export class GlobalFileManager {
|
||||
private static readonly STORAGE_KEY = 'globalFileRegistry';
|
||||
private static readonly SESSION_STORAGE_KEY = 'globalFileRegistrySession';
|
||||
|
||||
/**
|
||||
* 전역 파일 저장소 가져오기
|
||||
*/
|
||||
static getRegistry(): Record<string, GlobalFileInfo> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
|
||||
// 1. 메모리에서 먼저 확인
|
||||
if ((window as any).globalFileRegistry) {
|
||||
return (window as any).globalFileRegistry;
|
||||
}
|
||||
|
||||
// 2. sessionStorage에서 확인 (세션 동안 유지)
|
||||
const sessionData = sessionStorage.getItem(this.SESSION_STORAGE_KEY);
|
||||
if (sessionData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(sessionData);
|
||||
(window as any).globalFileRegistry = parsedData;
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
console.warn('세션 파일 데이터 파싱 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. localStorage에서 확인 (영구 저장)
|
||||
const localData = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (localData) {
|
||||
try {
|
||||
const parsedData = JSON.parse(localData);
|
||||
(window as any).globalFileRegistry = parsedData;
|
||||
return parsedData;
|
||||
} catch (error) {
|
||||
console.warn('로컬 파일 데이터 파싱 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일을 전역 저장소에 등록
|
||||
*/
|
||||
static registerFile(fileInfo: GlobalFileInfo): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const registry = this.getRegistry();
|
||||
registry[fileInfo.objid] = fileInfo;
|
||||
|
||||
// 메모리, 세션, 로컬스토리지에 모두 저장
|
||||
(window as any).globalFileRegistry = registry;
|
||||
sessionStorage.setItem(this.SESSION_STORAGE_KEY, JSON.stringify(registry));
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(registry));
|
||||
|
||||
console.log(`🌐 파일 등록됨: ${fileInfo.savedFileName} (총 ${Object.keys(registry).length}개)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 파일을 한번에 등록
|
||||
*/
|
||||
static registerFiles(files: FileInfo[], context: {
|
||||
uploadPage: string;
|
||||
componentId: string;
|
||||
screenId?: number;
|
||||
}): void {
|
||||
files.forEach(file => {
|
||||
const globalFileInfo: GlobalFileInfo = {
|
||||
...file,
|
||||
uploadPage: context.uploadPage,
|
||||
uploadTime: new Date().toISOString(),
|
||||
componentId: context.componentId,
|
||||
screenId: context.screenId,
|
||||
accessible: true,
|
||||
};
|
||||
this.registerFile(globalFileInfo);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 접근 가능한 파일 목록 가져오기
|
||||
*/
|
||||
static getAllAccessibleFiles(): GlobalFileInfo[] {
|
||||
const registry = this.getRegistry();
|
||||
return Object.values(registry).filter(file => file.accessible);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 페이지에서 업로드된 파일들 가져오기
|
||||
*/
|
||||
static getFilesByPage(pagePath: string): GlobalFileInfo[] {
|
||||
const registry = this.getRegistry();
|
||||
return Object.values(registry).filter(file =>
|
||||
file.uploadPage === pagePath && file.accessible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 화면에서 업로드된 파일들 가져오기
|
||||
*/
|
||||
static getFilesByScreen(screenId: number): GlobalFileInfo[] {
|
||||
const registry = this.getRegistry();
|
||||
return Object.values(registry).filter(file =>
|
||||
file.screenId === screenId && file.accessible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 검색 (이름으로)
|
||||
*/
|
||||
static searchFiles(query: string): GlobalFileInfo[] {
|
||||
const registry = this.getRegistry();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return Object.values(registry).filter(file =>
|
||||
file.accessible &&
|
||||
(file.realFileName?.toLowerCase().includes(lowerQuery) ||
|
||||
file.savedFileName?.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일을 접근 불가능하게 설정 (삭제 대신)
|
||||
*/
|
||||
static setFileAccessible(fileId: string, accessible: boolean): void {
|
||||
const registry = this.getRegistry();
|
||||
if (registry[fileId]) {
|
||||
registry[fileId].accessible = accessible;
|
||||
|
||||
// 저장소 업데이트
|
||||
(window as any).globalFileRegistry = registry;
|
||||
sessionStorage.setItem(this.SESSION_STORAGE_KEY, JSON.stringify(registry));
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(registry));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 저장소 초기화
|
||||
*/
|
||||
static clearRegistry(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
(window as any).globalFileRegistry = {};
|
||||
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
|
||||
console.log('🧹 전역 파일 저장소 초기화됨');
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장소 상태 정보
|
||||
*/
|
||||
static getRegistryInfo(): {
|
||||
totalFiles: number;
|
||||
accessibleFiles: number;
|
||||
pages: string[];
|
||||
screens: number[];
|
||||
} {
|
||||
const registry = this.getRegistry();
|
||||
const files = Object.values(registry);
|
||||
|
||||
return {
|
||||
totalFiles: files.length,
|
||||
accessibleFiles: files.filter(f => f.accessible).length,
|
||||
pages: [...new Set(files.map(f => f.uploadPage))],
|
||||
screens: [...new Set(files.map(f => f.screenId).filter(Boolean) as number[])],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,14 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
}) => {
|
||||
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
||||
// 디버깅: 전달받은 웹타입과 props 정보 로깅
|
||||
console.log("🔍 DynamicWebTypeRenderer 호출:", {
|
||||
webType,
|
||||
propsKeys: Object.keys(props),
|
||||
component: props.component,
|
||||
isFileComponent: props.component?.type === 'file' || webType === 'file'
|
||||
});
|
||||
|
||||
const webTypeDefinition = useMemo(() => {
|
||||
return WebTypeRegistry.getWebType(webType);
|
||||
|
|
@ -47,6 +55,17 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
};
|
||||
}, [props, mergedConfig, webType, onEvent]);
|
||||
|
||||
// 0순위: 파일 컴포넌트 강제 처리 (최우선)
|
||||
if (webType === "file" || props.component?.type === "file") {
|
||||
try {
|
||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||
console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
|
||||
return <FileUploadComponent {...props} {...finalProps} />;
|
||||
} catch (error) {
|
||||
console.error("FileUploadComponent 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선)
|
||||
if (dbWebType?.component_name) {
|
||||
try {
|
||||
|
|
@ -65,7 +84,9 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
||||
// return <ComponentByName {...props} {...finalProps} />;
|
||||
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
|
||||
return <div>컴포넌트 로딩 중...</div>;
|
||||
|
||||
// 로딩 중 메시지 대신 레지스트리로 폴백
|
||||
// return <div>컴포넌트 로딩 중...</div>;
|
||||
} catch (error) {
|
||||
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
|
||||
}
|
||||
|
|
@ -114,10 +135,36 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
return <FileUploadComponent {...props} {...finalProps} />;
|
||||
}
|
||||
|
||||
// const FallbackComponent = getWidgetComponentByWebType(webType);
|
||||
// return <FallbackComponent {...props} />;
|
||||
console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`);
|
||||
return <div>웹타입 로딩 중...</div>;
|
||||
// 텍스트 입력 웹타입들
|
||||
if (["text", "email", "password", "tel"].includes(webType)) {
|
||||
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
|
||||
console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
|
||||
return <TextInputComponent {...props} {...finalProps} />;
|
||||
}
|
||||
|
||||
// 숫자 입력 웹타입들
|
||||
if (["number", "decimal"].includes(webType)) {
|
||||
const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent");
|
||||
console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
|
||||
return <NumberInputComponent {...props} {...finalProps} />;
|
||||
}
|
||||
|
||||
// 날짜 입력 웹타입들
|
||||
if (["date", "datetime", "time"].includes(webType)) {
|
||||
const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent");
|
||||
console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
|
||||
return <DateInputComponent {...props} {...finalProps} />;
|
||||
}
|
||||
|
||||
// 기본 폴백: Input 컴포넌트 사용
|
||||
const { Input } = require("@/components/ui/input");
|
||||
console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
|
||||
return <Input
|
||||
placeholder={`${webType}`}
|
||||
disabled={props.readonly}
|
||||
className="w-full"
|
||||
{...props}
|
||||
/>;
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FileInfo, FileUploadConfig } from "./types";
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
Eye,
|
||||
File,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
Archive,
|
||||
Presentation,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { FileViewerModal } from "./FileViewerModal";
|
||||
|
||||
interface FileManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
uploadedFiles: FileInfo[];
|
||||
onFileUpload: (files: File[]) => Promise<void>;
|
||||
onFileDownload: (file: FileInfo) => void;
|
||||
onFileDelete: (file: FileInfo) => void;
|
||||
onFileView: (file: FileInfo) => void;
|
||||
config: FileUploadConfig;
|
||||
isDesignMode?: boolean;
|
||||
}
|
||||
|
||||
export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
uploadedFiles,
|
||||
onFileUpload,
|
||||
onFileDownload,
|
||||
onFileDelete,
|
||||
onFileView,
|
||||
config,
|
||||
isDesignMode = false,
|
||||
}) => {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 아이콘 가져오기
|
||||
const getFileIcon = (fileExt: string) => {
|
||||
const ext = fileExt.toLowerCase();
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
||||
return <ImageIcon className="w-5 h-5 text-blue-500" />;
|
||||
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
|
||||
return <FileText className="w-5 h-5 text-red-500" />;
|
||||
} else if (['xls', 'xlsx', 'csv'].includes(ext)) {
|
||||
return <FileText className="w-5 h-5 text-green-500" />;
|
||||
} else if (['ppt', 'pptx'].includes(ext)) {
|
||||
return <Presentation className="w-5 h-5 text-orange-500" />;
|
||||
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) {
|
||||
return <Video className="w-5 h-5 text-purple-500" />;
|
||||
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
|
||||
return <Music className="w-5 h-5 text-pink-500" />;
|
||||
} else if (['zip', 'rar', '7z'].includes(ext)) {
|
||||
return <Archive className="w-5 h-5 text-yellow-500" />;
|
||||
} else {
|
||||
return <File className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleFileUpload = async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fileArray = Array.from(files);
|
||||
await onFileUpload(fileArray);
|
||||
console.log('✅ FileManagerModal: 파일 업로드 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ FileManagerModal: 파일 업로드 오류:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
console.log('🔄 FileManagerModal: 업로드 상태 초기화');
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
|
||||
if (config.disabled || isDesignMode) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
handleFileUpload(files);
|
||||
};
|
||||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileSelect = () => {
|
||||
if (config.disabled || isDesignMode) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
// 입력값 초기화
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// 파일 뷰어 핸들러
|
||||
const handleFileViewInternal = (file: FileInfo) => {
|
||||
setViewerFile(file);
|
||||
setIsViewerOpen(true);
|
||||
};
|
||||
|
||||
const handleViewerClose = () => {
|
||||
setIsViewerOpen(false);
|
||||
setViewerFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden [&>button]:hidden">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
파일 관리 ({uploadedFiles.length}개)
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-gray-100"
|
||||
onClick={onClose}
|
||||
title="닫기"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col space-y-4 h-[70vh]">
|
||||
{/* 파일 업로드 영역 */}
|
||||
{!isDesignMode && (
|
||||
<div
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
|
||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
||||
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
||||
${uploading ? 'opacity-75' : ''}
|
||||
`}
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={config.multiple}
|
||||
accept={config.accept}
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={config.disabled}
|
||||
/>
|
||||
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
|
||||
<span className="text-blue-600 font-medium">업로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<p className="text-lg font-medium text-gray-900 mb-2">
|
||||
파일을 드래그하거나 클릭하여 업로드하세요
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{config.accept && `지원 형식: ${config.accept}`}
|
||||
{config.maxSize && ` • 최대 ${formatFileSize(config.maxSize)}`}
|
||||
{config.multiple && ' • 여러 파일 선택 가능'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-700">
|
||||
업로드된 파일
|
||||
</h3>
|
||||
{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-2">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div
|
||||
key={file.objid}
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<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>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleFileViewInternal(file)}
|
||||
title="미리보기"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onFileDownload(file)}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
{!isDesignMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => onFileDelete(file)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<File className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<p className="text-lg font-medium text-gray-600">업로드된 파일이 없습니다</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 드래그하거나 클릭하여 업로드하세요'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 파일 뷰어 모달 */}
|
||||
<FileViewerModal
|
||||
file={viewerFile}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={handleViewerClose}
|
||||
onDownload={onFileDownload}
|
||||
onDelete={!isDesignMode ? onFileDelete : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,9 +3,11 @@ 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 { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file";
|
||||
import { GlobalFileManager } from "@/lib/api/globalFile";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { FileViewerModal } from "./FileViewerModal";
|
||||
import { FileManagerModal } from "./FileManagerModal";
|
||||
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
||||
import {
|
||||
Upload,
|
||||
|
|
@ -75,13 +77,13 @@ export interface FileUploadComponentProps {
|
|||
onConfigChange?: (config: any) => void;
|
||||
}
|
||||
|
||||
export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
component,
|
||||
componentConfig,
|
||||
componentStyle,
|
||||
className,
|
||||
isInteractive,
|
||||
isDesignMode,
|
||||
isDesignMode = false, // 기본값 설정
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onClick,
|
||||
|
|
@ -94,55 +96,230 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
|
||||
useEffect(() => {
|
||||
if (!component?.id) return;
|
||||
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupFiles = localStorage.getItem(backupKey);
|
||||
if (backupFiles) {
|
||||
const parsedFiles = JSON.parse(backupFiles);
|
||||
if (parsedFiles.length > 0) {
|
||||
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
|
||||
componentId: component.id,
|
||||
restoredFiles: parsedFiles.length,
|
||||
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||
});
|
||||
setUploadedFiles(parsedFiles);
|
||||
|
||||
// 전역 상태에도 복원
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).globalFileState = {
|
||||
...(window as any).globalFileState,
|
||||
[component.id]: parsedFiles
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
|
||||
}
|
||||
}, [component.id]); // component.id가 변경될 때만 실행
|
||||
|
||||
// 템플릿 파일과 데이터 파일을 조회하는 함수
|
||||
const loadComponentFiles = useCallback(async () => {
|
||||
if (!component?.id) return;
|
||||
|
||||
try {
|
||||
const screenId = formData?.screenId || (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
? parseInt(window.location.pathname.split('/screens/')[1])
|
||||
: null);
|
||||
|
||||
if (!screenId) {
|
||||
console.log("📂 화면 ID 없음, 기존 파일 로직 사용");
|
||||
return false; // 기존 로직 사용
|
||||
}
|
||||
|
||||
const params = {
|
||||
screenId,
|
||||
componentId: component.id,
|
||||
tableName: formData?.tableName || component.tableName,
|
||||
recordId: formData?.id,
|
||||
columnName: component.columnName,
|
||||
};
|
||||
|
||||
console.log("📂 컴포넌트 파일 조회:", params);
|
||||
|
||||
const response = await getComponentFiles(params);
|
||||
|
||||
if (response.success) {
|
||||
console.log("📁 파일 조회 결과:", {
|
||||
templateFiles: response.templateFiles.length,
|
||||
dataFiles: response.dataFiles.length,
|
||||
totalFiles: response.totalFiles.length,
|
||||
summary: response.summary,
|
||||
actualFiles: response.totalFiles
|
||||
});
|
||||
|
||||
// 파일 데이터 형식 통일
|
||||
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
|
||||
}));
|
||||
|
||||
console.log("📁 형식 변환된 파일 데이터:", formattedFiles);
|
||||
|
||||
// 🔄 localStorage의 기존 파일과 서버 파일 병합
|
||||
let finalFiles = formattedFiles;
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupFiles = localStorage.getItem(backupKey);
|
||||
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];
|
||||
|
||||
console.log("🔄 파일 병합 완료:", {
|
||||
서버파일: formattedFiles.length,
|
||||
로컬파일: parsedBackupFiles.length,
|
||||
추가파일: additionalFiles.length,
|
||||
최종파일: finalFiles.length,
|
||||
최종파일목록: finalFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("파일 병합 중 오류:", e);
|
||||
}
|
||||
|
||||
setUploadedFiles(finalFiles);
|
||||
|
||||
// 전역 상태에도 저장
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).globalFileState = {
|
||||
...(window as any).globalFileState,
|
||||
[component.id]: finalFiles
|
||||
};
|
||||
|
||||
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
|
||||
GlobalFileManager.registerFiles(finalFiles, {
|
||||
uploadPage: window.location.pathname,
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
});
|
||||
|
||||
// localStorage 백업도 병합된 파일로 업데이트
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(finalFiles));
|
||||
console.log("💾 localStorage 백업 업데이트 완료:", finalFiles.length);
|
||||
} 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]);
|
||||
|
||||
// 컴포넌트 파일 동기화
|
||||
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 파일 동기화:", {
|
||||
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
|
||||
componentId: component.id,
|
||||
componentFiles: componentFiles.length,
|
||||
globalFiles: globalFiles.length,
|
||||
currentFiles: currentFiles.length,
|
||||
uploadedFiles: uploadedFiles.length,
|
||||
lastUpdate: lastUpdate
|
||||
formData: formData,
|
||||
screenId: formData?.screenId,
|
||||
currentUploadedFiles: uploadedFiles.length
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
// 먼저 새로운 템플릿 파일 조회 시도
|
||||
loadComponentFiles().then(useNewLogic => {
|
||||
if (useNewLogic) {
|
||||
console.log("✅ 새로운 템플릿 파일 로직 사용");
|
||||
return; // 새로운 로직이 성공했으면 기존 로직 스킵
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 복원 실패:", e);
|
||||
}
|
||||
|
||||
// 최신 파일과 현재 파일 비교
|
||||
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
|
||||
|
||||
// 기존 로직 사용
|
||||
console.log("📂 기존 파일 로직 사용");
|
||||
|
||||
// 전역 상태에서 최신 파일 정보 가져오기
|
||||
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,
|
||||
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
||||
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||||
lastUpdate: lastUpdate
|
||||
});
|
||||
setUploadedFiles(currentFiles);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
}
|
||||
}, [component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
|
||||
|
||||
// localStorage에서 백업 파일 복원 (새로고침 시 중요!)
|
||||
try {
|
||||
const backupKey = `fileUpload_${component.id}`;
|
||||
const backupFiles = localStorage.getItem(backupKey);
|
||||
if (backupFiles) {
|
||||
const parsedFiles = JSON.parse(backupFiles);
|
||||
if (parsedFiles.length > 0 && currentFiles.length === 0) {
|
||||
console.log("🔄 localStorage에서 파일 복원:", {
|
||||
componentId: component.id,
|
||||
restoredFiles: parsedFiles.length,
|
||||
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||
});
|
||||
setUploadedFiles(parsedFiles);
|
||||
|
||||
// 전역 상태에도 복원
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).globalFileState = {
|
||||
...(window as any).globalFileState,
|
||||
[component.id]: 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);
|
||||
}
|
||||
});
|
||||
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
|
||||
|
||||
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
|
||||
useEffect(() => {
|
||||
|
|
@ -164,9 +341,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
|
||||
console.log(logMessage, {
|
||||
componentId: component.id,
|
||||
이전파일수: uploadedFiles.length,
|
||||
새파일수: files.length,
|
||||
files: files.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||
이전파일수: uploadedFiles?.length || 0,
|
||||
새파일수: files?.length || 0,
|
||||
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || []
|
||||
});
|
||||
|
||||
setUploadedFiles(files);
|
||||
|
|
@ -203,8 +380,18 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileSelect = useCallback(() => {
|
||||
console.log("🎯 handleFileSelect 호출됨:", {
|
||||
hasFileInputRef: !!fileInputRef.current,
|
||||
fileInputRef: fileInputRef.current,
|
||||
fileInputType: fileInputRef.current?.type,
|
||||
fileInputHidden: fileInputRef.current?.className
|
||||
});
|
||||
|
||||
if (fileInputRef.current) {
|
||||
console.log("✅ fileInputRef.current.click() 호출");
|
||||
fileInputRef.current.click();
|
||||
} else {
|
||||
console.log("❌ fileInputRef.current가 null입니다");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -265,16 +452,31 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
toast.loading("파일을 업로드하는 중...", { id: 'file-upload' });
|
||||
|
||||
try {
|
||||
// targetObjid 생성 (InteractiveDataTable과 호환)
|
||||
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
|
||||
const tableName = formData?.tableName || component.tableName || 'default_table';
|
||||
const recordId = formData?.id || 'temp_record';
|
||||
const recordId = formData?.id;
|
||||
const screenId = formData?.screenId;
|
||||
const columnName = component.columnName || component.id;
|
||||
const targetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
|
||||
let targetObjid;
|
||||
if (recordId && tableName) {
|
||||
// 실제 데이터 파일
|
||||
targetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||
console.log("📁 실제 데이터 파일 업로드:", targetObjid);
|
||||
} else if (screenId) {
|
||||
// 템플릿 파일
|
||||
targetObjid = `screen_${screenId}:${component.id}`;
|
||||
console.log("🎨 템플릿 파일 업로드:", targetObjid);
|
||||
} else {
|
||||
// 기본값 (화면관리에서 사용)
|
||||
targetObjid = `temp_${component.id}`;
|
||||
console.log("📝 기본 파일 업로드:", targetObjid);
|
||||
}
|
||||
|
||||
const uploadData = {
|
||||
tableName: tableName,
|
||||
fieldName: columnName,
|
||||
recordId: recordId,
|
||||
recordId: recordId || `temp_${component.id}`,
|
||||
docType: component.fileConfig?.docType || 'DOCUMENT',
|
||||
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
|
||||
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||
|
|
@ -358,6 +560,13 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
globalFileState[component.id] = updatedFiles;
|
||||
(window as any).globalFileState = globalFileState;
|
||||
|
||||
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
||||
GlobalFileManager.registerFiles(newFiles, {
|
||||
uploadPage: window.location.pathname,
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
});
|
||||
|
||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
||||
detail: {
|
||||
|
|
@ -429,6 +638,11 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
if (safeComponentConfig.onFileUpload) {
|
||||
safeComponentConfig.onFileUpload(newFiles);
|
||||
}
|
||||
|
||||
// 성공 시 토스트 처리
|
||||
setUploadStatus('idle');
|
||||
toast.dismiss('file-upload');
|
||||
toast.success(`${newFiles.length}개 파일 업로드 완료`);
|
||||
} else {
|
||||
console.error("❌ 파일 업로드 실패:", response);
|
||||
throw new Error(response.message || (response as any).error || '파일 업로드에 실패했습니다.');
|
||||
|
|
@ -436,15 +650,31 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
} catch (error) {
|
||||
console.error('파일 업로드 오류:', error);
|
||||
setUploadStatus('error');
|
||||
toast.dismiss();
|
||||
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(file.objid, file.realFileName);
|
||||
await downloadFile({
|
||||
fileId: file.objid,
|
||||
serverFilename: file.savedFileName,
|
||||
originalName: file.realFileName
|
||||
});
|
||||
toast.success(`${file.realFileName} 다운로드 완료`);
|
||||
} catch (error) {
|
||||
console.error('파일 다운로드 오류:', error);
|
||||
|
|
@ -458,7 +688,8 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
const fileId = typeof file === 'string' ? file : file.objid;
|
||||
const fileName = typeof file === 'string' ? '파일' : file.realFileName;
|
||||
|
||||
await deleteFile(fileId);
|
||||
const serverFilename = typeof file === 'string' ? 'temp_file' : file.savedFileName;
|
||||
await deleteFile(fileId, serverFilename);
|
||||
|
||||
const updatedFiles = uploadedFiles.filter(f => f.objid !== fileId);
|
||||
setUploadedFiles(updatedFiles);
|
||||
|
|
@ -512,25 +743,23 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}
|
||||
}, [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) => {
|
||||
console.log("🎯 드래그 오버 이벤트 감지:", {
|
||||
readonly: safeComponentConfig.readonly,
|
||||
disabled: safeComponentConfig.disabled,
|
||||
dragOver: dragOver
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
||||
setDragOver(true);
|
||||
console.log("✅ 드래그 오버 활성화");
|
||||
} else {
|
||||
console.log("❌ 드래그 차단됨: readonly 또는 disabled");
|
||||
}
|
||||
}, [safeComponentConfig.readonly, safeComponentConfig.disabled]);
|
||||
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -553,27 +782,53 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
|
||||
// 클릭 핸들러
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
console.log("🖱️ 파일 업로드 영역 클릭:", {
|
||||
readonly: safeComponentConfig.readonly,
|
||||
disabled: safeComponentConfig.disabled,
|
||||
hasHandleFileSelect: !!handleFileSelect
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
||||
console.log("✅ 파일 선택 함수 호출");
|
||||
handleFileSelect();
|
||||
} else {
|
||||
console.log("❌ 클릭 차단됨: readonly 또는 disabled");
|
||||
}
|
||||
onClick?.();
|
||||
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick]);
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={className}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<div
|
||||
style={{
|
||||
...componentStyle,
|
||||
border: 'none !important',
|
||||
boxShadow: 'none !important',
|
||||
outline: 'none !important',
|
||||
backgroundColor: 'transparent !important',
|
||||
padding: '0px !important',
|
||||
borderRadius: '0px !important',
|
||||
marginBottom: '8px !important'
|
||||
}}
|
||||
className={`${className} file-upload-container`}
|
||||
>
|
||||
{/* 라벨 렌더링 - 주석처리 */}
|
||||
{/* {component.label && component.style?.labelDisplay !== false && (
|
||||
<label
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-25px",
|
||||
top: "-20px",
|
||||
left: "0px",
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#3b83f6",
|
||||
fontWeight: "500",
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
fontSize: "12px",
|
||||
color: "rgb(107, 114, 128)",
|
||||
fontWeight: "400",
|
||||
background: "transparent !important",
|
||||
border: "none !important",
|
||||
boxShadow: "none !important",
|
||||
outline: "none !important",
|
||||
padding: "0px !important",
|
||||
margin: "0px !important"
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
|
|
@ -581,18 +836,22 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
<span style={{ color: "#ef4444" }}>*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<div className="w-full h-full flex flex-col space-y-2">
|
||||
{/* 디자인 모드가 아닐 때만 파일 업로드 영역 표시 */}
|
||||
{!isDesignMode && (
|
||||
<div
|
||||
className="w-full h-full flex flex-col space-y-2"
|
||||
style={{ minHeight: '120px' }}
|
||||
>
|
||||
{/* 파일 업로드 영역 - 주석처리 */}
|
||||
{/* {!isDesignMode && (
|
||||
<div
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
||||
border border-dashed rounded p-2 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' : ''}
|
||||
`}
|
||||
style={{ minHeight: '50px' }}
|
||||
onClick={handleClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -603,13 +862,13 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={safeComponentConfig.multiple}
|
||||
accept={safeComponentConfig.accept}
|
||||
onChange={handleInputChange}
|
||||
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">
|
||||
|
|
@ -620,60 +879,82 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
) : (
|
||||
<>
|
||||
<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 && ' • 여러 파일 선택 가능'}
|
||||
<Upload className="mx-auto h-6 w-6 text-gray-400 mb-2" />
|
||||
<p className="text-xs font-medium text-gray-600">
|
||||
파일 업로드
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* 업로드된 파일 목록 - 디자인 모드에서는 항상 표시 */}
|
||||
{(uploadedFiles.length > 0 || isDesignMode) && (
|
||||
{/* 업로드된 파일 목록 - 항상 표시 */}
|
||||
{(() => {
|
||||
const shouldShow = true; // 항상 표시하도록 강제
|
||||
console.log("🎯🎯🎯 파일 목록 렌더링 조건 체크:", {
|
||||
uploadedFilesLength: uploadedFiles.length,
|
||||
isDesignMode: isDesignMode,
|
||||
shouldShow: shouldShow,
|
||||
uploadedFiles: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName })),
|
||||
"🚨 렌더링 여부": shouldShow ? "✅ 렌더링됨" : "❌ 렌더링 안됨"
|
||||
});
|
||||
return shouldShow;
|
||||
})() && (
|
||||
<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">
|
||||
<h4 className="text-sm font-medium text-gray-700" style={{ textShadow: 'none', boxShadow: 'none' }}>
|
||||
업로드된 파일 ({uploadedFiles.length})
|
||||
</h4>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
{uploadedFiles.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setIsFileManagerOpen(true)}
|
||||
style={{
|
||||
boxShadow: 'none !important',
|
||||
textShadow: 'none !important',
|
||||
filter: 'none !important',
|
||||
WebkitBoxShadow: 'none !important',
|
||||
MozBoxShadow: 'none !important'
|
||||
}}
|
||||
>
|
||||
자세히보기
|
||||
</Button>
|
||||
</div>
|
||||
</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 key={file.objid} className="flex items-center space-x-3 p-2 bg-gray-50 rounded text-sm hover:bg-gray-100 transition-colors" style={{ boxShadow: 'none', textShadow: 'none' }}>
|
||||
<div className="flex-shrink-0">
|
||||
{getFileIcon(file.fileExt)}
|
||||
</div>
|
||||
<span className="flex-1 truncate text-gray-900">
|
||||
<span className="flex-1 truncate text-gray-900 cursor-pointer" onClick={() => handleFileView(file)} style={{ textShadow: 'none' }}>
|
||||
{file.realFileName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-gray-500" style={{ textShadow: 'none' }}>
|
||||
{formatFileSize(file.fileSize)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-gray-500 mt-2 text-center">
|
||||
💡 파일 관리는 상세설정에서 가능합니다
|
||||
<div className="text-xs text-gray-500 mt-2 text-center" style={{ textShadow: 'none' }}>
|
||||
💡 파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500" style={{ textShadow: 'none' }}>
|
||||
<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>
|
||||
<p className="text-sm font-medium" style={{ textShadow: 'none' }}>업로드된 파일이 없습니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1" style={{ textShadow: 'none' }}>상세설정에서 파일을 업로드하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -694,6 +975,20 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
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}
|
||||
config={safeComponentConfig}
|
||||
isDesignMode={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,39 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } 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 { Download, X, AlertTriangle, FileText, Trash2, ExternalLink } from "lucide-react";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { API_BASE_URL } from "@/lib/api/client";
|
||||
|
||||
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
|
||||
const loadOfficeLibrariesFromCDN = async () => {
|
||||
if (typeof window === 'undefined') return { XLSX: null, mammoth: null };
|
||||
|
||||
try {
|
||||
// XLSX 라이브러리가 이미 로드되어 있는지 확인
|
||||
if (!(window as any).XLSX) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// mammoth 라이브러리가 이미 로드되어 있는지 확인
|
||||
if (!(window as any).mammoth) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js';
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
XLSX: (window as any).XLSX,
|
||||
mammoth: (window as any).mammoth
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Office 라이브러리 CDN 로드 실패:', error);
|
||||
return { XLSX: null, mammoth: null };
|
||||
}
|
||||
};
|
||||
|
||||
interface FileViewerModalProps {
|
||||
file: FileInfo | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDownload?: (file: FileInfo) => void;
|
||||
onDelete?: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 뷰어 모달 컴포넌트
|
||||
* 다양한 파일 타입에 대한 미리보기 기능 제공
|
||||
*/
|
||||
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||
file,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [renderedContent, setRenderedContent] = useState<string | null>(null);
|
||||
|
||||
// Office 문서를 CDN 라이브러리로 렌더링하는 함수
|
||||
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// CDN에서 라이브러리 로드
|
||||
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
|
||||
|
||||
if (fileExt === "docx" && mammoth) {
|
||||
// Word 문서 렌더링
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
|
||||
const htmlContent = `
|
||||
<div>
|
||||
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
|
||||
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
|
||||
${result.value || '내용을 읽을 수 없습니다.'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
|
||||
// Excel 문서 렌더링
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
const html = XLSX.utils.sheet_to_html(worksheet, {
|
||||
table: { className: 'excel-table' }
|
||||
});
|
||||
|
||||
const htmlContent = `
|
||||
<div>
|
||||
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
|
||||
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">시트: ${sheetName}</p>
|
||||
<div style="max-height: 400px; overflow: auto; border: 1px solid #ddd; border-radius: 5px;">
|
||||
<style>
|
||||
.excel-table { border-collapse: collapse; width: 100%; }
|
||||
.excel-table td, .excel-table th { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 12px; }
|
||||
.excel-table th { background-color: #f5f5f5; font-weight: bold; }
|
||||
</style>
|
||||
${html}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (fileExt === "doc") {
|
||||
// .doc 파일은 .docx로 변환 안내
|
||||
const htmlContent = `
|
||||
<div style="text-align: center; padding: 40px;">
|
||||
<h3 style="color: #333; margin-bottom: 15px;">📄 ${fileName}</h3>
|
||||
<p style="color: #666; margin-bottom: 10px;">.doc 파일은 .docx로 변환 후 업로드해주세요.</p>
|
||||
<p style="color: #666; font-size: 14px;">(.docx 파일만 미리보기 지원)</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
} else if (["ppt", "pptx"].includes(fileExt)) {
|
||||
// PowerPoint는 미리보기 불가 안내
|
||||
const htmlContent = `
|
||||
<div style="text-align: center; padding: 40px;">
|
||||
<h3 style="color: #333; margin-bottom: 15px;">📑 ${fileName}</h3>
|
||||
<p style="color: #666; margin-bottom: 10px;">PowerPoint 파일은 브라우저에서 미리보기할 수 없습니다.</p>
|
||||
<p style="color: #666; font-size: 14px;">파일을 다운로드하여 확인해주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // 지원하지 않는 형식
|
||||
} catch (error) {
|
||||
console.error("Office 문서 렌더링 오류:", error);
|
||||
|
||||
const htmlContent = `
|
||||
<div style="color: red; text-align: center; padding: 20px;">
|
||||
Office 문서를 읽을 수 없습니다.<br>
|
||||
파일이 손상되었거나 지원하지 않는 형식일 수 있습니다.
|
||||
</div>
|
||||
`;
|
||||
|
||||
setRenderedContent(htmlContent);
|
||||
return true; // 오류 메시지라도 표시
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일이 변경될 때마다 미리보기 URL 생성
|
||||
useEffect(() => {
|
||||
if (!file || !isOpen) {
|
||||
setPreviewUrl(null);
|
||||
setPreviewError(null);
|
||||
setRenderedContent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -49,16 +186,18 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
// 서버 파일인 경우 - 미리보기 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 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,
|
||||
|
|
@ -68,22 +207,97 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
];
|
||||
|
||||
if (supportedExtensions.includes(fileExt)) {
|
||||
// 실제 환경에서는 파일 서빙 API 엔드포인트 사용
|
||||
const url = `/api/files/preview/${file.objid}`;
|
||||
setPreviewUrl(url);
|
||||
// 이미지나 PDF는 인증된 요청으로 Blob 생성
|
||||
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
|
||||
try {
|
||||
// 인증된 요청으로 파일 데이터 가져오기
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("authToken")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewUrl(blobUrl);
|
||||
|
||||
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
|
||||
cleanup = () => URL.revokeObjectURL(blobUrl);
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 미리보기 로드 실패:", error);
|
||||
setPreviewError("파일을 불러올 수 없습니다. 권한을 확인해주세요.");
|
||||
}
|
||||
} else if (documentExtensions.includes(fileExt)) {
|
||||
// Office 문서는 OnlyOffice 또는 안정적인 뷰어 사용
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
|
||||
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
|
||||
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
|
||||
try {
|
||||
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
|
||||
|
||||
if (!renderSuccess) {
|
||||
// 렌더링 실패 시 Blob URL 사용
|
||||
setPreviewUrl(blobUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Office 문서 렌더링 중 오류:", error);
|
||||
// 오류 발생 시 Blob URL 사용
|
||||
setPreviewUrl(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// 기타 문서는 직접 Blob URL 사용
|
||||
setPreviewUrl(blobUrl);
|
||||
}
|
||||
|
||||
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Office 문서 로드 실패:", error);
|
||||
// 오류 발생 시 다운로드 옵션 제공
|
||||
setPreviewError(`${fileExt.toUpperCase()} 문서를 미리보기할 수 없습니다. 다운로드하여 확인해주세요.`);
|
||||
}
|
||||
} else {
|
||||
// 기타 파일은 다운로드 URL 사용
|
||||
const url = `${API_BASE_URL.replace("/api", "")}/api/files/download/${file.objid}`;
|
||||
setPreviewUrl(url);
|
||||
}
|
||||
} else {
|
||||
// 지원하지 않는 파일 타입
|
||||
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('미리보기 URL 생성 오류:', error);
|
||||
setPreviewError('미리보기를 불러오는데 실패했습니다.');
|
||||
console.error("미리보기 URL 생성 오류:", error);
|
||||
setPreviewError("미리보기를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
generatePreviewUrl();
|
||||
|
||||
// cleanup 함수 반환
|
||||
return () => {
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [file, isOpen]);
|
||||
|
||||
if (!file) return null;
|
||||
|
|
@ -100,8 +314,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
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" />
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||
<p className="text-sm text-center">{previewError}</p>
|
||||
<Button
|
||||
|
|
@ -119,121 +333,163 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
const fileExt = file.fileExt.toLowerCase();
|
||||
|
||||
// 이미지 파일
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(fileExt)) {
|
||||
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 || ''}
|
||||
src={previewUrl || ""}
|
||||
alt={file.realFileName}
|
||||
className="max-w-full max-h-full object-contain rounded-lg"
|
||||
onError={() => setPreviewError('이미지를 불러올 수 없습니다.')}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
|
||||
onError={(e) => {
|
||||
console.error("이미지 로드 오류:", previewUrl, e);
|
||||
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log("이미지 로드 성공:", previewUrl);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 텍스트 파일
|
||||
if (['txt', 'md', 'json', 'xml', 'csv'].includes(fileExt)) {
|
||||
if (["txt", "md", "json", "xml", "csv"].includes(fileExt)) {
|
||||
return (
|
||||
<div className="h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ''}
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
title={`${file.realFileName} 미리보기`}
|
||||
onError={() => setPreviewError('텍스트 파일을 불러올 수 없습니다.')}
|
||||
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PDF 파일
|
||||
if (fileExt === 'pdf') {
|
||||
if (fileExt === "pdf") {
|
||||
return (
|
||||
<div className="h-96">
|
||||
<div className="h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ''}
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
title={`${file.realFileName} 미리보기`}
|
||||
onError={() => setPreviewError('PDF 파일을 불러올 수 없습니다.')}
|
||||
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>
|
||||
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe)
|
||||
if (
|
||||
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"].includes(fileExt)
|
||||
) {
|
||||
// CDN 라이브러리로 렌더링된 콘텐츠가 있는 경우
|
||||
if (renderedContent) {
|
||||
return (
|
||||
<div className="relative h-96 overflow-auto">
|
||||
<div
|
||||
className="w-full h-full p-4 border rounded-lg bg-white"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
파일 다운로드
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// iframe 방식 (fallback)
|
||||
return (
|
||||
<div className="relative h-96 overflow-auto">
|
||||
<iframe
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border rounded-lg"
|
||||
onError={() => {
|
||||
console.log("iframe 오류 발생, fallback 옵션 제공");
|
||||
setPreviewError("이 Office 문서는 브라우저에서 직접 미리보기할 수 없습니다. 다운로드하여 확인해주세요.");
|
||||
}}
|
||||
title={`${file.realFileName} 미리보기`}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Office 문서를 처리하는 중...</p>
|
||||
<p className="text-xs text-gray-400 mt-1">잠시만 기다려주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오류 발생 시 fallback 옵션 */}
|
||||
{previewError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white">
|
||||
<FileText className="w-16 h-16 mb-4 text-orange-500" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 제한</p>
|
||||
<p className="text-sm text-center mb-4 text-gray-600">
|
||||
{previewError}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// 새 탭에서 파일 열기 시도
|
||||
const link = document.createElement('a');
|
||||
link.href = previewUrl || '';
|
||||
link.target = '_blank';
|
||||
link.click();
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
새 탭에서 열기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 비디오 파일
|
||||
if (['mp4', 'webm', 'ogg'].includes(fileExt)) {
|
||||
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('비디오를 재생할 수 없습니다.')}
|
||||
className="w-full max-h-96"
|
||||
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
|
||||
>
|
||||
<source src={previewUrl || ''} type={`video/${fileExt}`} />
|
||||
브라우저가 비디오 재생을 지원하지 않습니다.
|
||||
<source src={previewUrl || ""} type={`video/${fileExt}`} />
|
||||
</video>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 오디오 파일
|
||||
if (['mp3', 'wav', 'ogg'].includes(fileExt)) {
|
||||
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" />
|
||||
<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('오디오를 재생할 수 없습니다.')}
|
||||
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
|
||||
>
|
||||
<source src={previewUrl || ''} type={`audio/${fileExt}`} />
|
||||
브라우저가 오디오 재생을 지원하지 않습니다.
|
||||
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
|
||||
</audio>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -241,8 +497,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
|
||||
// 기타 파일 타입
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
|
||||
<FileText className="w-16 h-16 mb-4" />
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<FileText className="w-16 h-16 mb-4 text-gray-400" />
|
||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||
<p className="text-sm text-center mb-4">
|
||||
{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.
|
||||
|
|
@ -259,8 +515,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
|
|
@ -271,40 +527,54 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
|||
{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>
|
||||
<DialogDescription>
|
||||
파일 크기: {formatFileSize(file.size)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 파일 미리보기 영역 */}
|
||||
<div className="flex-1 overflow-auto py-4">
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
|
||||
{/* 파일 정보 및 액션 버튼 */}
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
|
||||
<span>크기: {formatFileSize(file.size)}</span>
|
||||
{file.uploadedAt && (
|
||||
<span>업로드: {new Date(file.uploadedAt).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDownload?.(file)}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
다운로드
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => onDelete(file)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
@ -13,14 +13,14 @@ export interface FileInfo {
|
|||
fileSize: number;
|
||||
fileExt: string;
|
||||
filePath: string;
|
||||
docType: string;
|
||||
docTypeName: string;
|
||||
docType?: string;
|
||||
docTypeName?: string;
|
||||
targetObjid: string;
|
||||
parentTargetObjid?: string;
|
||||
companyCode: string;
|
||||
writer: string;
|
||||
regdate: string;
|
||||
status: string;
|
||||
companyCode?: string;
|
||||
writer?: string;
|
||||
regdate?: string;
|
||||
status?: string;
|
||||
|
||||
// 추가 호환성 속성들
|
||||
path?: string; // filePath와 동일
|
||||
|
|
@ -97,6 +97,7 @@ export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
|
|||
export interface FileUploadResponse {
|
||||
success: boolean;
|
||||
data?: FileInfo[];
|
||||
files?: FileInfo[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* 컴포넌트 타입 유틸리티
|
||||
* 레거시와 신규 컴포넌트 시스템 모두 지원하는 타입 감지 함수들
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
/**
|
||||
* 파일 컴포넌트 여부를 확인합니다
|
||||
*
|
||||
* 지원하는 타입:
|
||||
* - 레거시: type="file"
|
||||
* - 레거시: type="widget" + widgetType="file"
|
||||
* - 신규: type="component" + widgetType="file"
|
||||
* - 신규: type="component" + componentType="file-upload"
|
||||
* - 신규: type="component" + componentConfig.webType="file"
|
||||
*/
|
||||
export const isFileComponent = (component: ComponentData): boolean => {
|
||||
return component.type === "file" ||
|
||||
(component.type === "widget" && (component as any).widgetType === "file") ||
|
||||
(component.type === "component" &&
|
||||
((component as any).widgetType === "file" || // ✅ ScreenDesigner에서 설정됨
|
||||
(component as any).componentType === "file-upload" || // ✅ ComponentRegistry ID
|
||||
(component as any).componentConfig?.webType === "file")); // ✅ componentConfig 내부
|
||||
};
|
||||
|
||||
/**
|
||||
* 버튼 컴포넌트 여부를 확인합니다
|
||||
*/
|
||||
export const isButtonComponent = (component: ComponentData): boolean => {
|
||||
return component.type === "button" ||
|
||||
(component.type === "widget" && (component as any).widgetType === "button") ||
|
||||
(component.type === "component" &&
|
||||
((component as any).webType === "button" ||
|
||||
(component as any).componentType === "button"));
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터 테이블 컴포넌트 여부를 확인합니다
|
||||
*/
|
||||
export const isDataTableComponent = (component: ComponentData): boolean => {
|
||||
return component.type === "datatable" ||
|
||||
(component.type === "component" &&
|
||||
((component as any).componentType === "datatable" ||
|
||||
(component as any).componentType === "data-table"));
|
||||
};
|
||||
|
||||
/**
|
||||
* 위젯 컴포넌트 여부를 확인합니다
|
||||
*/
|
||||
export const isWidgetComponent = (component: ComponentData): boolean => {
|
||||
return component.type === "widget";
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 웹타입을 가져옵니다
|
||||
*/
|
||||
export const getComponentWebType = (component: ComponentData): string | undefined => {
|
||||
// 파일 컴포넌트는 무조건 "file" 웹타입 반환
|
||||
if (isFileComponent(component)) {
|
||||
console.log(`🎯 파일 컴포넌트 감지 → webType: "file" 반환`, {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
widgetType: (component as any).widgetType,
|
||||
componentConfig: (component as any).componentConfig
|
||||
});
|
||||
return "file";
|
||||
}
|
||||
|
||||
if (component.type === "widget") {
|
||||
return (component as any).widgetType;
|
||||
}
|
||||
if (component.type === "component") {
|
||||
return (component as any).widgetType || (component as any).componentConfig?.webType;
|
||||
}
|
||||
return component.type;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 실제 타입을 가져옵니다 (신규 시스템용)
|
||||
*/
|
||||
export const getComponentType = (component: ComponentData): string => {
|
||||
if (component.type === "component") {
|
||||
return (component as any).componentType || (component as any).webType || "unknown";
|
||||
}
|
||||
return component.type;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트가 입력 가능한 컴포넌트인지 확인합니다
|
||||
*/
|
||||
export const isInputComponent = (component: ComponentData): boolean => {
|
||||
const inputTypes = ["text", "number", "email", "password", "tel", "url", "search",
|
||||
"textarea", "select", "checkbox", "radio", "date", "time",
|
||||
"datetime-local", "file", "code", "entity"];
|
||||
|
||||
const webType = getComponentWebType(component);
|
||||
return webType ? inputTypes.includes(webType) : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트가 표시용 컴포넌트인지 확인합니다
|
||||
*/
|
||||
export const isDisplayComponent = (component: ComponentData): boolean => {
|
||||
const displayTypes = ["label", "text", "image", "video", "chart", "table", "datatable"];
|
||||
|
||||
const webType = getComponentWebType(component);
|
||||
return webType ? displayTypes.includes(webType) : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 필드명을 가져옵니다
|
||||
*/
|
||||
export const getComponentFieldName = (component: ComponentData): string => {
|
||||
return (component as any).columnName || component.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트의 라벨을 가져옵니다
|
||||
*/
|
||||
export const getComponentLabel = (component: ComponentData): string => {
|
||||
return (component as any).label || (component as any).title || component.id;
|
||||
};
|
||||
|
|
@ -39,7 +39,9 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx-preview": "^0.3.6",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "15.4.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.9.0",
|
||||
|
|
@ -47,8 +49,10 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -3341,6 +3345,15 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz",
|
||||
|
|
@ -3396,6 +3409,22 @@
|
|||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
|
||||
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"printj": "~1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"adler32": "bin/adler32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
|
|
@ -3695,6 +3724,32 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
|
|
@ -3827,6 +3882,28 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb/node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
|
@ -3929,6 +4006,28 @@
|
|||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
|
||||
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"commander": "~2.14.1",
|
||||
"exit-on-epipe": "~1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"codepage": "bin/codepage.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage/node_modules/commander": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
|
||||
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
|
|
@ -3986,6 +4085,12 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
|
||||
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
|
@ -4010,6 +4115,24 @@
|
|||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -4323,6 +4446,12 @@
|
|||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
|
|
@ -4336,6 +4465,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx-preview": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz",
|
||||
"integrity": "sha512-gKVPE18hlpfuhQHiptsw1rbOwzQeGSwK10/w7hv1ZMEqHmjtCuTpz6AUMfu1twIPGxgpcsMXThKI6B6WsP3L1w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jszip": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
|
|
@ -4349,6 +4487,15 @@
|
|||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duck": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||
"license": "BSD",
|
||||
"dependencies": {
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -5064,6 +5211,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exit-on-epipe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
|
||||
|
|
@ -5278,6 +5434,15 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
|
@ -5592,6 +5757,12 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -5619,6 +5790,12 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
|
|
@ -6154,6 +6331,18 @@
|
|||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
|
@ -6198,6 +6387,15 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
|
|
@ -6473,6 +6671,17 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lop": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"duck": "^0.1.12",
|
||||
"option": "~0.2.1",
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.525.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
|
||||
|
|
@ -6492,6 +6701,39 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
|
||||
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.8.6",
|
||||
"argparse": "~1.0.3",
|
||||
"base64-js": "^1.5.1",
|
||||
"bluebird": "~3.4.0",
|
||||
"dingbat-to-unicode": "^1.0.1",
|
||||
"jszip": "^3.7.1",
|
||||
"lop": "^0.4.2",
|
||||
"path-is-absolute": "^1.0.0",
|
||||
"underscore": "^1.13.1",
|
||||
"xmlbuilder": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mammoth": "bin/mammoth"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -6893,6 +7135,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/option": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -6961,6 +7209,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -6984,6 +7238,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
|
|
@ -7211,6 +7474,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/printj": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
|
||||
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"printj": "bin/printj.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz",
|
||||
|
|
@ -7237,6 +7512,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -7475,6 +7756,27 @@
|
|||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
|
|
@ -7629,6 +7931,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
|
|
@ -7732,6 +8040,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz",
|
||||
|
|
@ -7798,6 +8112,28 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sheetjs-style": {
|
||||
"version": "0.15.8",
|
||||
"resolved": "https://registry.npmjs.org/sheetjs-style/-/sheetjs-style-0.15.8.tgz",
|
||||
"integrity": "sha512-/wRiwnq5ck7aO+zLBs+u5JqQK4agUTIGCS0nxgaMjFl6XdlVaaB/RNJcP6S6Efj3+RYbSZuAoyqmSnbzxfT7Kg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.2.0",
|
||||
"cfb": "^1.1.4",
|
||||
"codepage": "~1.14.0",
|
||||
"commander": "~2.17.1",
|
||||
"crc-32": "~1.2.0",
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"ssf": "~0.10.3",
|
||||
"wmf": "~1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
|
|
@ -7903,6 +8239,27 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.10.3.tgz",
|
||||
"integrity": "sha512-pRuUdW0WwyB2doSqqjWyzwCD6PkfxpHAHdZp39K3dp/Hq7f+xfMwNAWIi16DyrRg4gg9c/RvLYkJTSawTPTm1w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"ssf": "bin/ssf.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
|
|
@ -7924,6 +8281,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
|
|
@ -8408,6 +8774,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
|
@ -8512,6 +8884,12 @@
|
|||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -8617,6 +8995,24 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
@ -8627,6 +9023,66 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx/node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx/node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx/node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx-preview": "^0.3.6",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "15.4.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.9.0",
|
||||
|
|
@ -55,8 +57,10 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ export interface FileComponent extends BaseComponent {
|
|||
type: "file";
|
||||
fileConfig: FileTypeConfig;
|
||||
uploadedFiles?: UploadedFile[];
|
||||
columnName?: string;
|
||||
tableName?: string;
|
||||
lastFileUpdate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -193,12 +196,21 @@ export interface TextTypeConfig {
|
|||
* 파일 타입 설정
|
||||
*/
|
||||
export interface FileTypeConfig {
|
||||
accept?: string;
|
||||
accept?: string[];
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // bytes
|
||||
maxSize?: number; // MB
|
||||
maxFiles?: number;
|
||||
preview?: boolean;
|
||||
showPreview?: boolean;
|
||||
showProgress?: boolean;
|
||||
docType?: string;
|
||||
docTypeName?: string;
|
||||
dragDropText?: string;
|
||||
uploadButtonText?: string;
|
||||
autoUpload?: boolean;
|
||||
chunkedUpload?: boolean;
|
||||
linkedTable?: string;
|
||||
linkedField?: string;
|
||||
autoLink?: boolean;
|
||||
companyCode?: CompanyCode;
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +284,8 @@ export interface UploadedFile {
|
|||
filePath: string;
|
||||
docType?: string;
|
||||
docTypeName?: string;
|
||||
targetObjid: string;
|
||||
parentTargetObjid?: string;
|
||||
writer?: string;
|
||||
regdate?: string;
|
||||
status?: "uploading" | "completed" | "error";
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
테스트 파일입니다.
|
||||
Loading…
Reference in New Issue