From d73be8a4d36c9ad7edbfe5386486843f3be08037 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Sep 2025 21:52:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/install-multer.js | 18 + backend-node/prisma/schema.prisma | 6 +- .../src/controllers/authController.ts | 20 +- .../src/controllers/fileController.ts | 444 +++++++++++++ backend-node/src/routes/fileRoutes.ts | 506 +-------------- backend-node/src/services/authService.ts | 19 + .../src/services/tableManagementService.ts | 125 +++- .../screen/InteractiveDataTable.tsx | 139 ++-- .../screen/InteractiveScreenViewer.tsx | 84 ++- .../components/screen/RealtimePreview.tsx | 20 + frontend/components/screen/ScreenDesigner.tsx | 51 ++ .../screen/panels/DetailSettingsPanel.tsx | 35 +- .../panels/FileComponentConfigPanel.tsx | 338 ++++++++++ .../screen/panels/TemplatesPanel.tsx | 31 +- .../components/screen/widgets/FileUpload.tsx | 608 ++++++++++++++++++ frontend/hooks/useAuth.ts | 3 +- frontend/lib/api/client.ts | 6 + frontend/lib/api/file.ts | 2 +- frontend/package-lock.json | 34 +- frontend/package.json | 4 +- frontend/types/screen.ts | 62 +- 21 files changed, 1999 insertions(+), 556 deletions(-) create mode 100644 backend-node/install-multer.js create mode 100644 backend-node/src/controllers/fileController.ts create mode 100644 frontend/components/screen/panels/FileComponentConfigPanel.tsx create mode 100644 frontend/components/screen/widgets/FileUpload.tsx diff --git a/backend-node/install-multer.js b/backend-node/install-multer.js new file mode 100644 index 00000000..aab43e62 --- /dev/null +++ b/backend-node/install-multer.js @@ -0,0 +1,18 @@ +// multer 패키지 설치 스크립트 +const { exec } = require("child_process"); + +console.log("📦 multer 패키지 설치 중..."); + +exec("npm install multer @types/multer", (error, stdout, stderr) => { + if (error) { + console.error("❌ 설치 실패:", error); + return; + } + + if (stderr) { + console.log("⚠️ 경고:", stderr); + } + + console.log("✅ multer 설치 완료"); + console.log(stdout); +}); diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 7859e626..5bcce352 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -243,6 +243,7 @@ model attach_file_info { file_size Decimal? @db.Decimal file_ext String? @default("NULL::character varying") @db.VarChar(32) file_path String? @default("NULL::character varying") @db.VarChar(512) + company_code String? @default("default") @db.VarChar(32) writer String? @default("NULL::character varying") @db.VarChar(32) regdate DateTime? @db.Timestamp(6) status String? @default("NULL::character varying") @db.VarChar(32) @@ -250,7 +251,10 @@ model attach_file_info { @@index([doc_type, objid], map: "attach_file_info_doc_type_idx") @@index([target_objid]) - @@ignore + @@index([company_code], map: "attach_file_info_company_code_idx") + @@index([company_code, doc_type], map: "attach_file_info_company_doc_type_idx") + @@index([company_code, target_objid], map: "attach_file_info_company_target_idx") + @@id([objid]) } model authority_master { diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 43a82f2e..c78b4918 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -181,20 +181,38 @@ export class AuthController { return; } - const userInfoResponse: UserInfo = { + // DB에서 조회한 원본 사용자 정보 로그 + console.log("🔍 DB에서 조회한 사용자 정보:", { + userId: dbUserInfo.userId, + companyCode: dbUserInfo.companyCode, + deptCode: dbUserInfo.deptCode, + dbUserInfoKeys: Object.keys(dbUserInfo), + }); + + // 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환 + const userInfoResponse: any = { userId: dbUserInfo.userId, userName: dbUserInfo.userName || "", deptName: dbUserInfo.deptName || "", companyCode: dbUserInfo.companyCode || "ILSHIN", + company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성 userType: dbUserInfo.userType || "USER", userTypeName: dbUserInfo.userTypeName || "일반사용자", email: dbUserInfo.email || "", photo: dbUserInfo.photo, locale: dbUserInfo.locale || "KR", // locale 정보 추가 + deptCode: dbUserInfo.deptCode, // 추가 필드 isAdmin: dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin", }; + console.log("📤 프론트엔드로 전송할 사용자 정보:", { + companyCode: userInfoResponse.companyCode, + company_code: userInfoResponse.company_code, + deptCode: userInfoResponse.deptCode, + responseKeys: Object.keys(userInfoResponse), + }); + res.status(200).json({ success: true, message: "사용자 정보 조회 성공", diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts new file mode 100644 index 00000000..9652d0de --- /dev/null +++ b/backend-node/src/controllers/fileController.ts @@ -0,0 +1,444 @@ +import { Request, Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import multer from "multer"; +import path from "path"; +import fs from "fs"; +import { PrismaClient } from "@prisma/client"; +import { generateUUID } from "../utils/generateId"; + +const prisma = new PrismaClient(); + +// 업로드 디렉토리 설정 (회사별로 분리) +const baseUploadDir = path.join(process.cwd(), "uploads"); +if (!fs.existsSync(baseUploadDir)) { + fs.mkdirSync(baseUploadDir, { recursive: true }); +} + +// 회사별 + 날짜별 디렉토리 생성 함수 +const getCompanyUploadDir = (companyCode: string, dateFolder?: string) => { + // 회사코드가 *인 경우 company_*로 변환 + const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode; + + // 날짜 폴더가 제공되지 않은 경우 오늘 날짜 사용 (YYYY/MM/DD 형식) + if (!dateFolder) { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + dateFolder = `${year}/${month}/${day}`; + } + + const companyDir = path.join(baseUploadDir, actualCompanyCode, dateFolder); + if (!fs.existsSync(companyDir)) { + fs.mkdirSync(companyDir, { recursive: true }); + } + return companyDir; +}; + +// Multer 설정 +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + // 임시 디렉토리에 저장 (나중에 올바른 위치로 이동) + const tempDir = path.join(baseUploadDir, "temp"); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + console.log(`📁 임시 업로드 디렉토리: ${tempDir}`); + cb(null, tempDir); + }, + filename: (req, file, cb) => { + // 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨) + const timestamp = Date.now(); + const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, "_"); + const savedFileName = `${timestamp}_${sanitizedName}`; + console.log(`📄 저장 파일명: ${savedFileName}`); + cb(null, savedFileName); + }, +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 50 * 1024 * 1024, // 50MB 제한 + }, + fileFilter: (req, file, cb) => { + // 파일 타입 검증 + const allowedTypes = [ + "image/jpeg", + "image/png", + "image/gif", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ]; + + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error("허용되지 않는 파일 타입입니다.")); + } + }, +}); + +/** + * 파일 업로드 및 attach_file_info 테이블에 저장 + */ +export const uploadFiles = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + console.log("📤 파일 업로드 요청 수신:", { + body: req.body, + companyCode: req.body.companyCode, + writer: req.body.writer, + docType: req.body.docType, + user: req.user + ? { + userId: req.user.userId, + companyCode: req.user.companyCode, + deptCode: req.user.deptCode, + } + : "no user", + files: req.files + ? (req.files as Express.Multer.File[]).map((f) => f.originalname) + : "none", + }); + + if (!req.files || (req.files as Express.Multer.File[]).length === 0) { + res.status(400).json({ + success: false, + message: "업로드할 파일이 없습니다.", + }); + return; + } + + const files = req.files as Express.Multer.File[]; + const { + docType = "DOCUMENT", + docTypeName = "일반 문서", + targetObjid, + parentTargetObjid, + } = req.body; + + // 회사코드와 작성자 정보 결정 (우선순위: 요청 body > 사용자 토큰 정보 > 기본값) + const companyCode = + req.body.companyCode || (req.user as any)?.companyCode || "DEFAULT"; + const writer = req.body.writer || (req.user as any)?.userId || "system"; + + console.log("🔍 사용자 정보 결정:", { + bodyCompanyCode: req.body.companyCode, + userCompanyCode: (req.user as any)?.companyCode, + finalCompanyCode: companyCode, + bodyWriter: req.body.writer, + userWriter: (req.user as any)?.userId, + finalWriter: writer, + }); + + const savedFiles = []; + + for (const file of files) { + // 파일 확장자 추출 + const fileExt = path + .extname(file.originalname) + .toLowerCase() + .replace(".", ""); + + // 파일 경로 설정 (회사별 + 날짜별 디렉토리 구조 반영) + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const dateFolder = `${year}/${month}/${day}`; + + // 회사코드가 *인 경우 company_*로 변환 + const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode; + const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`; + const fullFilePath = `/uploads${relativePath}`; + + console.log("📂 파일 경로 설정:", { + companyCode, + filename: file.filename, + relativePath, + fullFilePath, + }); + + // 임시 파일을 최종 위치로 이동 + const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로 + const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder); + const finalFilePath = path.join(finalUploadDir, file.filename); + + console.log("📦 파일 이동:", { + from: tempFilePath, + to: finalFilePath, + }); + + // 파일 이동 + fs.renameSync(tempFilePath, finalFilePath); + + // attach_file_info 테이블에 저장 + const fileRecord = await prisma.attach_file_info.create({ + data: { + objid: parseInt( + generateUUID().replace(/-/g, "").substring(0, 15), + 16 + ), + target_objid: targetObjid, + saved_file_name: file.filename, + real_file_name: file.originalname, + doc_type: docType, + doc_type_name: docTypeName, + file_size: file.size, + file_ext: fileExt, + file_path: fullFilePath, // 회사별 디렉토리 포함된 경로 + company_code: companyCode, // 회사코드 추가 + writer: writer, + regdate: new Date(), + status: "ACTIVE", + parent_target_objid: parentTargetObjid, + }, + }); + + console.log("💾 파일 정보 DB 저장 완료:", { + objid: fileRecord.objid.toString(), + saved_file_name: fileRecord.saved_file_name, + real_file_name: fileRecord.real_file_name, + file_size: fileRecord.file_size?.toString(), + }); + + savedFiles.push({ + objid: fileRecord.objid.toString(), + savedFileName: fileRecord.saved_file_name, + realFileName: fileRecord.real_file_name, + fileSize: Number(fileRecord.file_size), + fileExt: fileRecord.file_ext, + filePath: fileRecord.file_path, + docType: fileRecord.doc_type, + docTypeName: fileRecord.doc_type_name, + targetObjid: fileRecord.target_objid, + parentTargetObjid: fileRecord.parent_target_objid, + companyCode: companyCode, // 실제 전달받은 회사코드 + writer: fileRecord.writer, + regdate: fileRecord.regdate?.toISOString(), + status: fileRecord.status, + }); + + console.log("✅ 파일 저장 결과:", { + objid: fileRecord.objid.toString(), + company_code: companyCode, + file_path: fileRecord.file_path, + writer: fileRecord.writer, + }); + } + + res.json({ + success: true, + message: `${files.length}개 파일 업로드 완료`, + files: savedFiles, + }); + } catch (error) { + console.error("파일 업로드 오류:", error); + res.status(500).json({ + success: false, + message: "파일 업로드 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } +}; + +/** + * 파일 삭제 (논리적 삭제) + */ +export const deleteFile = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { objid } = req.params; + const { writer = "system" } = req.body; + + console.log("🗑️ 파일 삭제 요청:", { objid, writer }); + + // 파일 상태를 DELETED로 변경 (논리적 삭제) + const deletedFile = await prisma.attach_file_info.update({ + where: { + objid: parseInt(objid), + }, + data: { + status: "DELETED", + }, + }); + + console.log("✅ 파일 삭제 완료 (논리적):", { + objid: deletedFile.objid.toString(), + status: deletedFile.status, + }); + + res.json({ + success: true, + message: "파일이 삭제되었습니다.", + }); + } catch (error) { + console.error("파일 삭제 오류:", error); + res.status(500).json({ + success: false, + message: "파일 삭제 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } +}; + +/** + * 파일 목록 조회 + */ +export const getFileList = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { targetObjid, docType, companyCode } = req.query; + + console.log("📋 파일 목록 조회 요청:", { + targetObjid, + docType, + companyCode, + }); + + const where: any = { + status: "ACTIVE", + }; + + if (targetObjid) { + where.target_objid = targetObjid as string; + } + + if (docType) { + where.doc_type = docType as string; + } + + const files = await prisma.attach_file_info.findMany({ + where, + orderBy: { + regdate: "desc", + }, + }); + + const fileList = files.map((file: any) => ({ + 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, + })); + + res.json({ + success: true, + files: fileList, + }); + } catch (error) { + console.error("파일 목록 조회 오류:", error); + res.status(500).json({ + success: false, + message: "파일 목록 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } +}; + +/** + * 파일 다운로드 + */ +export const downloadFile = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { objid } = req.params; + + console.log("📥 파일 다운로드 요청:", { objid }); + + const fileRecord = await prisma.attach_file_info.findUnique({ + where: { + objid: parseInt(objid), + }, + }); + + if (!fileRecord || fileRecord.status !== "ACTIVE") { + res.status(404).json({ + success: false, + message: "파일을 찾을 수 없습니다.", + }); + return; + } + + // 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /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_* 추출 + const fileName = fileRecord.saved_file_name!; + + // 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD) + let dateFolder = ""; + if (filePathParts.length >= 6) { + // /uploads/company_*/2025/09/05/filename.ext 형태 + dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`; + } + + const companyUploadDir = getCompanyUploadDir( + companyCode, + dateFolder || undefined + ); + const filePath = path.join(companyUploadDir, fileName); + + console.log("📥 파일 다운로드 경로 확인:", { + stored_file_path: fileRecord.file_path, + company_code: companyCode, + company_upload_dir: companyUploadDir, + final_file_path: filePath, + }); + + if (!fs.existsSync(filePath)) { + console.error("❌ 파일 없음:", filePath); + res.status(404).json({ + success: false, + message: `실제 파일을 찾을 수 없습니다: ${filePath}`, + }); + return; + } + + // 파일 다운로드 헤더 설정 + res.setHeader( + "Content-Disposition", + `attachment; filename="${encodeURIComponent(fileRecord.real_file_name!)}"` + ); + res.setHeader("Content-Type", "application/octet-stream"); + + // 파일 스트림 전송 + const fileStream = fs.createReadStream(filePath); + fileStream.pipe(res); + + console.log("✅ 파일 다운로드 시작:", { + objid: fileRecord.objid.toString(), + real_file_name: fileRecord.real_file_name, + }); + } catch (error) { + console.error("파일 다운로드 오류:", error); + res.status(500).json({ + success: false, + message: "파일 다운로드 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } +}; + +// Multer 미들웨어 export +export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일 diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index b9f0f8f8..3a99b53d 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -1,497 +1,45 @@ -import express from "express"; -import multer from "multer"; -import path from "path"; -import fs from "fs"; +import { Router } from "express"; +import { + uploadFiles, + deleteFile, + getFileList, + downloadFile, + uploadMiddleware, +} from "../controllers/fileController"; import { authenticateToken } from "../middleware/authMiddleware"; -import { AuthenticatedRequest } from "../types/auth"; -import { logger } from "../utils/logger"; -import { FileSystemManager } from "../utils/fileSystemManager"; -const router = express.Router(); +const router = Router(); -// 파일 저장 경로 설정 -const UPLOAD_PATH = path.join(process.cwd(), "uploads"); - -// uploads 디렉토리가 없으면 생성 -if (!fs.existsSync(UPLOAD_PATH)) { - fs.mkdirSync(UPLOAD_PATH, { recursive: true }); -} - -// Multer 설정 - 회사별 폴더 구조 지원 -const storage = multer.diskStorage({ - destination: (req, file, cb) => { - try { - // 사용자의 회사 코드 가져오기 - const user = (req as AuthenticatedRequest).user; - const companyCode = user?.companyCode || "default"; - - // 회사별 날짜별 폴더 생성 - const uploadPath = FileSystemManager.createCompanyUploadPath(companyCode); - - logger.info("파일 업로드 대상 폴더", { - companyCode, - uploadPath, - userId: user?.userId, - }); - - return cb(null, uploadPath); - } catch (error) { - logger.error("업로드 폴더 생성 실패", error); - return cb(error as Error, ""); - } - }, - filename: (req, file, cb) => { - try { - // 사용자의 회사 코드 가져오기 - const user = (req as AuthenticatedRequest).user; - const companyCode = user?.companyCode || "default"; - - // 회사코드가 포함된 안전한 파일명 생성 - const safeFileName = FileSystemManager.generateSafeFileName( - file.originalname, - companyCode - ); - - logger.info("파일명 생성", { - originalName: file.originalname, - safeFileName, - companyCode, - userId: user?.userId, - }); - - return cb(null, safeFileName); - } catch (error) { - logger.error("파일명 생성 실패", error); - return cb(error as Error, ""); - } - }, -}); - -const upload = multer({ - storage, - limits: { - fileSize: 50 * 1024 * 1024, // 50MB 제한 - }, - fileFilter: (req, file, cb) => { - // 허용된 파일 타입 검사 (필요시 확장) - const allowedTypes = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "text/plain", - "text/csv", - ]; - - if (allowedTypes.includes(file.mimetype)) { - return cb(null, true); - } else { - return cb(new Error(`허용되지 않는 파일 타입입니다: ${file.mimetype}`)); - } - }, -}); - -// 모든 라우트에 인증 미들웨어 적용 +// 모든 파일 API는 인증 필요 router.use(authenticateToken); /** - * 파일 업로드 - * POST /api/files/upload + * @route POST /api/files/upload + * @desc 파일 업로드 (attach_file_info 테이블에 저장) + * @access Private */ -router.post( - "/upload", - upload.array("files", 10), - async (req: AuthenticatedRequest, res): Promise => { - try { - const files = req.files as Express.Multer.File[]; - - if (!files || files.length === 0) { - res.status(400).json({ - success: false, - message: "업로드할 파일이 없습니다.", - }); - return; - } - - const fileInfos = files.map((file) => ({ - id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - name: Buffer.from(file.originalname, "latin1").toString("utf8"), - size: file.size, - type: file.mimetype, - extension: path.extname(file.originalname).toLowerCase().substring(1), - uploadedAt: new Date().toISOString(), - lastModified: new Date().toISOString(), - serverPath: file.path, - serverFilename: file.filename, - })); - - logger.info("파일 업로드 완료", { - userId: req.user?.userId, - fileCount: files.length, - files: fileInfos.map((f) => ({ name: f.name, size: f.size })), - }); - - res.json({ - success: true, - message: `${files.length}개 파일이 성공적으로 업로드되었습니다.`, - files: fileInfos, - }); - } catch (error) { - logger.error("파일 업로드 오류:", error); - res.status(500).json({ - success: false, - message: "파일 업로드 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } -); +router.post("/upload", uploadMiddleware, uploadFiles); /** - * 파일 다운로드 - * GET /api/files/download/:fileId + * @route GET /api/files + * @desc 파일 목록 조회 + * @query targetObjid, docType, companyCode + * @access Private */ -router.get( - "/download/:fileId", - async (req: AuthenticatedRequest, res): Promise => { - try { - const { fileId } = req.params; - const { serverFilename, originalName } = req.query; - - if (!serverFilename || !originalName) { - res.status(400).json({ - success: false, - message: - "파일 정보가 부족합니다. (serverFilename, originalName 필요)", - }); - return; - } - - // 회사별 폴더 구조를 고려하여 파일 경로 찾기 - const user = req.user; - const companyCode = user?.companyCode || "default"; - - // 먼저 회사별 폴더에서 찾기 - let filePath = FileSystemManager.findFileInCompanyFolders( - companyCode, - serverFilename as string - ); - - // 찾지 못하면 기본 uploads 폴더에서 찾기 (하위 호환성) - if (!filePath) { - filePath = path.join(UPLOAD_PATH, serverFilename as string); - } - - // 파일 존재 확인 - if (!fs.existsSync(filePath)) { - logger.warn("파일을 찾을 수 없음", { - fileId, - serverFilename, - filePath, - companyCode, - userId: user?.userId, - }); - - res.status(404).json({ - success: false, - message: "요청한 파일을 찾을 수 없습니다.", - }); - return; - } - - // 파일 정보 확인 - const stats = fs.statSync(filePath); - - logger.info("파일 다운로드 요청", { - fileId, - originalName, - serverFilename, - fileSize: stats.size, - userId: req.user?.userId, - }); - - // 파일명 인코딩 (한글 파일명 지원) - const encodedFilename = encodeURIComponent(originalName as string); - - // 응답 헤더 설정 - res.setHeader( - "Content-Disposition", - `attachment; filename*=UTF-8''${encodedFilename}` - ); - res.setHeader("Content-Type", "application/octet-stream"); - res.setHeader("Content-Length", stats.size); - res.setHeader("Cache-Control", "no-cache"); - - // 파일 스트림으로 전송 - const fileStream = fs.createReadStream(filePath); - - fileStream.on("error", (error) => { - logger.error("파일 스트림 오류:", error); - if (!res.headersSent) { - res.status(500).json({ - success: false, - message: "파일 전송 중 오류가 발생했습니다.", - }); - } - }); - - fileStream.pipe(res); - } catch (error) { - logger.error("파일 다운로드 오류:", error); - res.status(500).json({ - success: false, - message: "파일 다운로드 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } -); +router.get("/", getFileList); /** - * 파일 삭제 - * DELETE /api/files/:fileId + * @route DELETE /api/files/:objid + * @desc 파일 삭제 (논리적 삭제) + * @access Private */ -router.delete( - "/:fileId", - async (req: AuthenticatedRequest, res): Promise => { - try { - const { fileId } = req.params; - const { serverFilename } = req.body; - - if (!serverFilename) { - res.status(400).json({ - success: false, - message: "서버 파일명이 필요합니다.", - }); - return; - } - - // 회사별 폴더 구조를 고려하여 파일 경로 찾기 - const user = req.user; - const companyCode = user?.companyCode || "default"; - - // 먼저 회사별 폴더에서 찾기 - let filePath = FileSystemManager.findFileInCompanyFolders( - companyCode, - serverFilename - ); - - // 찾지 못하면 기본 uploads 폴더에서 찾기 (하위 호환성) - if (!filePath) { - filePath = path.join(UPLOAD_PATH, serverFilename); - } - - // 파일 존재 확인 및 삭제 - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - logger.info("파일 삭제 완료", { - fileId, - serverFilename, - filePath, - companyCode, - userId: user?.userId, - }); - } else { - logger.warn("삭제할 파일을 찾을 수 없음", { - fileId, - serverFilename, - companyCode, - userId: user?.userId, - }); - } - - res.json({ - success: true, - message: "파일이 성공적으로 삭제되었습니다.", - }); - } catch (error) { - logger.error("파일 삭제 오류:", error); - res.status(500).json({ - success: false, - message: "파일 삭제 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } -); +router.delete("/:objid", deleteFile); /** - * 이미지 미리보기 - * GET /api/files/preview/:fileId + * @route GET /api/files/download/:objid + * @desc 파일 다운로드 + * @access Private */ -router.get( - "/preview/:fileId", - async (req: AuthenticatedRequest, res): Promise => { - try { - const { fileId } = req.params; - const { serverFilename } = req.query; - - if (!serverFilename) { - res.status(400).json({ - success: false, - message: "서버 파일명이 필요합니다.", - }); - return; - } - - // 회사별 폴더 구조를 고려하여 파일 경로 찾기 - const user = req.user; - const companyCode = user?.companyCode || "default"; - - // 먼저 회사별 폴더에서 찾기 - let filePath = FileSystemManager.findFileInCompanyFolders( - companyCode, - serverFilename as string - ); - - // 찾지 못하면 기본 uploads 폴더에서 찾기 - if (!filePath) { - filePath = path.join(UPLOAD_PATH, serverFilename as string); - } - - // 파일 존재 확인 - if (!fs.existsSync(filePath)) { - logger.warn("이미지 파일을 찾을 수 없음", { - fileId, - serverFilename, - filePath, - userId: user?.userId, - }); - - res.status(404).json({ - success: false, - message: "요청한 이미지 파일을 찾을 수 없습니다.", - }); - return; - } - - // 파일 확장자로 MIME 타입 결정 - const ext = path.extname(filePath).toLowerCase(); - const mimeTypes: { [key: string]: string } = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".svg": "image/svg+xml", - }; - - const mimeType = mimeTypes[ext] || "application/octet-stream"; - - // 이미지 파일이 아닌 경우 에러 반환 - if (!mimeType.startsWith("image/")) { - res.status(400).json({ - success: false, - message: "이미지 파일이 아닙니다.", - }); - return; - } - - // 파일 정보 확인 - const stats = fs.statSync(filePath); - - logger.info("이미지 미리보기 요청", { - fileId, - serverFilename, - mimeType, - fileSize: stats.size, - userId: user?.userId, - }); - - // 캐시 헤더 설정 (이미지는 캐시 가능) - res.setHeader("Content-Type", mimeType); - res.setHeader("Content-Length", stats.size); - res.setHeader("Cache-Control", "public, max-age=86400"); // 24시간 캐시 - res.setHeader("Last-Modified", stats.mtime.toUTCString()); - - // If-Modified-Since 헤더 확인 - const ifModifiedSince = req.headers["if-modified-since"]; - if (ifModifiedSince && new Date(ifModifiedSince) >= stats.mtime) { - res.status(304).end(); - return; - } - - // 파일 스트림으로 전송 - const fileStream = fs.createReadStream(filePath); - - fileStream.on("error", (error) => { - logger.error("이미지 스트림 오류:", error); - if (!res.headersSent) { - res.status(500).json({ - success: false, - message: "이미지 전송 중 오류가 발생했습니다.", - }); - } - }); - - fileStream.pipe(res); - } catch (error) { - logger.error("이미지 미리보기 오류:", error); - res.status(500).json({ - success: false, - message: "이미지 미리보기 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } -); - -/** - * 파일 정보 조회 - * GET /api/files/info/:fileId - */ -router.get( - "/info/:fileId", - async (req: AuthenticatedRequest, res): Promise => { - try { - const { fileId } = req.params; - const { serverFilename } = req.query; - - if (!serverFilename) { - res.status(400).json({ - success: false, - message: "서버 파일명이 필요합니다.", - }); - return; - } - - const filePath = path.join(UPLOAD_PATH, serverFilename as string); - - if (!fs.existsSync(filePath)) { - res.status(404).json({ - success: false, - message: "파일을 찾을 수 없습니다.", - }); - return; - } - - const stats = fs.statSync(filePath); - - res.json({ - success: true, - data: { - fileId, - serverFilename, - size: stats.size, - lastModified: stats.mtime.toISOString(), - exists: true, - }, - }); - } catch (error) { - logger.error("파일 정보 조회 오류:", error); - res.status(500).json({ - success: false, - message: "파일 정보 조회 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "알 수 없는 오류", - }); - } - } -); +router.get("/download/:objid", downloadFile); export default router; diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 8a1a9720..b5e7f0bb 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -185,6 +185,19 @@ export class AuthService { }, }); + // DB에서 조회한 원본 사용자 정보 상세 로그 + console.log("🔍 AuthService - DB 원본 사용자 정보:", { + userId: userInfo.user_id, + company_code: userInfo.company_code, + company_code_type: typeof userInfo.company_code, + company_code_is_null: userInfo.company_code === null, + company_code_is_undefined: userInfo.company_code === undefined, + company_code_is_empty: userInfo.company_code === "", + dept_code: userInfo.dept_code, + allUserFields: Object.keys(userInfo), + companyInfo: companyInfo?.company_name, + }); + // PersonBean 형태로 변환 (null 값을 undefined로 변환) const personBean: PersonBean = { userId: userInfo.user_id, @@ -209,6 +222,12 @@ export class AuthService { locale: userInfo.locale || "KR", }; + console.log("📦 AuthService - 최종 PersonBean:", { + userId: personBean.userId, + companyCode: personBean.companyCode, + deptCode: personBean.deptCode, + }); + logger.info(`사용자 정보 조회 완료: ${userId}`); return personBean; } catch (error) { diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7abda41a..bfb392c6 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -529,6 +529,121 @@ export class TableManagementService { } } + /** + * 파일 데이터 보강 (attach_file_info에서 파일 정보 가져오기) + */ + private async enrichFileData( + data: any[], + fileColumns: string[], + tableName: string + ): Promise { + try { + logger.info( + `파일 데이터 보강 시작: ${tableName}, ${fileColumns.join(", ")}` + ); + + // 각 행의 파일 정보를 보강 + const enrichedData = await Promise.all( + data.map(async (row) => { + const enrichedRow = { ...row }; + + // 각 파일 컬럼에 대해 처리 + for (const fileColumn of fileColumns) { + const filePath = row[fileColumn]; + if (filePath && typeof filePath === "string") { + // 파일 경로에서 실제 파일 정보 조회 + const fileInfo = await this.getFileInfoByPath(filePath); + if (fileInfo) { + // 파일 정보를 JSON 형태로 저장 + enrichedRow[fileColumn] = JSON.stringify({ + files: [fileInfo], + totalCount: 1, + totalSize: fileInfo.size, + }); + } + } + } + + return enrichedRow; + }) + ); + + logger.info(`파일 데이터 보강 완료: ${enrichedData.length}개 행 처리`); + return enrichedData; + } catch (error) { + logger.error("파일 데이터 보강 실패:", error); + return data; // 실패 시 원본 데이터 반환 + } + } + + /** + * 파일 경로로 파일 정보 조회 + */ + private async getFileInfoByPath(filePath: string): Promise { + try { + const fileInfo = await prisma.attach_file_info.findFirst({ + where: { + file_path: filePath, + status: "ACTIVE", + }, + select: { + objid: true, + real_file_name: true, + file_size: true, + file_ext: true, + file_path: true, + doc_type: true, + doc_type_name: true, + regdate: true, + writer: true, + }, + }); + + if (!fileInfo) { + return null; + } + + return { + name: fileInfo.real_file_name, + path: fileInfo.file_path, + size: Number(fileInfo.file_size) || 0, + type: fileInfo.file_ext, + objid: fileInfo.objid.toString(), + docType: fileInfo.doc_type, + docTypeName: fileInfo.doc_type_name, + regdate: fileInfo.regdate?.toISOString(), + writer: fileInfo.writer, + }; + } catch (error) { + logger.warn(`파일 정보 조회 실패: ${filePath}`, error); + return null; + } + } + + /** + * 파일 타입 컬럼 조회 + */ + private async getFileTypeColumns(tableName: string): Promise { + try { + const fileColumns = await prisma.column_labels.findMany({ + where: { + table_name: tableName, + web_type: "file", + }, + select: { + column_name: true, + }, + }); + + const columnNames = fileColumns.map((col: any) => col.column_name); + logger.info(`파일 타입 컬럼 감지: ${tableName}`, columnNames); + return columnNames; + } catch (error) { + logger.warn(`파일 타입 컬럼 조회 실패: ${tableName}`, error); + return []; + } + } + /** * 테이블 데이터 조회 (페이징 + 검색) */ @@ -554,6 +669,9 @@ export class TableManagementService { logger.info(`테이블 데이터 조회: ${tableName}`, options); + // 🎯 파일 타입 컬럼 감지 + const fileColumns = await this.getFileTypeColumns(tableName); + // WHERE 조건 구성 let whereConditions: string[] = []; let searchValues: any[] = []; @@ -610,13 +728,18 @@ export class TableManagementService { LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - const data = await prisma.$queryRawUnsafe( + let data = await prisma.$queryRawUnsafe( dataQuery, ...searchValues, size, offset ); + // 🎯 파일 컬럼이 있으면 파일 정보 보강 + if (fileColumns.length > 0) { + data = await this.enrichFileData(data, fileColumns, safeTableName); + } + const totalPages = Math.ceil(total / size); logger.info( diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 868310d7..ace54aef 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1292,8 +1292,10 @@ export const InteractiveDataTable: React.FC = ({ try { console.log("📥 파일 다운로드 시작:", fileInfo); - // serverFilename이 없는 경우 처리 - if (!fileInfo.serverFilename) { + // serverFilename이 없는 경우 파일 경로에서 추출 시도 + const serverFilename = fileInfo.serverFilename || (fileInfo.path ? fileInfo.path.split("/").pop() : null); + + if (!serverFilename) { // _file 속성이 있는 경우 로컬 파일로 다운로드 if ((fileInfo as any)._file) { console.log("📁 로컬 파일 다운로드 시도:", fileInfo.name); @@ -1337,8 +1339,8 @@ export const InteractiveDataTable: React.FC = ({ toast.loading(`${fileInfo.name} 다운로드 중...`); await downloadFile({ - fileId: fileInfo.id, - serverFilename: fileInfo.serverFilename, + fileId: fileInfo.objid || fileInfo.id, + serverFilename: serverFilename, originalName: fileInfo.name, }); @@ -1367,39 +1369,94 @@ export const InteractiveDataTable: React.FC = ({ // file_path 컬럼은 강제로 파일 타입으로 처리 (임시 해결책) const isFileColumn = column.widgetType === "file" || column.columnName === "file_path"; - switch (column.widgetType) { - case "file": - console.log("🗂️ 파일 타입 컬럼 처리 중:", value); - if (value) { - try { + // file_path 컬럼도 파일 타입으로 처리 + if (isFileColumn) { + console.log("🗂️ 파일 타입 컬럼 처리 중:", value); + if (value) { + try { + let fileData; + + // 파일 경로 문자열인지 확인 (/uploads/로 시작하는 경우) + if (typeof value === "string" && value.startsWith("/uploads/")) { + // 파일 경로 문자열인 경우 단일 파일로 처리 + const fileName = value.split("/").pop() || "파일"; + const fileExt = fileName.split(".").pop()?.toLowerCase() || ""; + fileData = { + files: [ + { + name: fileName.replace(/^\d+_/, ""), // 타임스탬프 제거 + path: value, + objid: Date.now().toString(), // 임시 objid + size: 0, // 크기 정보 없음 + type: + fileExt === "jpg" || fileExt === "jpeg" + ? "image/jpeg" + : fileExt === "png" + ? "image/png" + : fileExt === "gif" + ? "image/gif" + : fileExt === "pdf" + ? "application/pdf" + : "application/octet-stream", + extension: fileExt, + regdate: new Date().toISOString(), // 등록일 추가 + writer: "시스템", // 기본 등록자 + }, + ], + totalCount: 1, + totalSize: 0, + regdate: new Date().toISOString(), // 파일 데이터 전체에도 등록일 추가 + }; + } else { // JSON 문자열이면 파싱 - const fileData = typeof value === "string" ? JSON.parse(value) : value; - console.log("📁 파싱된 파일 데이터:", fileData); + fileData = typeof value === "string" ? JSON.parse(value) : value; - if (fileData?.files && Array.isArray(fileData.files) && fileData.files.length > 0) { - return ( -
- - - {(fileData.totalSize / 1024 / 1024).toFixed(1)}MB - -
- ); + // regdate가 없는 경우 기본값 설정 + if (!fileData.regdate) { + fileData.regdate = new Date().toISOString(); } - } catch (error) { - console.warn("파일 데이터 파싱 오류:", error); - } - } - return 파일 없음; + // 개별 파일들에도 regdate와 writer가 없는 경우 추가 + if (fileData.files && Array.isArray(fileData.files)) { + fileData.files.forEach((file: any) => { + if (!file.regdate) { + file.regdate = new Date().toISOString(); + } + if (!file.writer) { + file.writer = "시스템"; + } + }); + } + } + + console.log("📁 파싱된 파일 데이터:", fileData); + + if (fileData?.files && Array.isArray(fileData.files) && fileData.files.length > 0) { + return ( +
+ + + {(fileData.totalSize / 1024 / 1024).toFixed(1)}MB + +
+ ); + } + } catch (error) { + console.warn("파일 데이터 파싱 오류:", error); + } + } + return 파일 없음; + } + + switch (column.widgetType) { case "date": if (value) { try { @@ -1840,14 +1897,11 @@ export const InteractiveDataTable: React.FC = ({ 타입: {fileInfo.type || "알 수 없음"}
- 확장자: {fileInfo.extension || "N/A"} - 업로드: {new Date(fileInfo.uploadedAt).toLocaleString("ko-KR")} + {fileInfo.regdate && ( + 등록일: {new Date(fileInfo.regdate).toLocaleString("ko-KR")} + )} + {fileInfo.writer && 등록자: {fileInfo.writer}}
- {fileInfo.lastModified && ( -
- 수정: {new Date(fileInfo.lastModified).toLocaleString("ko-KR")} -
- )} @@ -1896,11 +1950,6 @@ export const InteractiveDataTable: React.FC = ({ {" "} {(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB -
- 최종 수정: - {" "} - {new Date(currentFileData.lastModified).toLocaleString("ko-KR")} -
)} diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 9c0da012..b9560d63 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -19,6 +19,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, + FileComponent, TextTypeConfig, NumberTypeConfig, DateTypeConfig, @@ -32,6 +33,7 @@ import { ButtonTypeConfig, } from "@/types/screen"; import { InteractiveDataTable } from "./InteractiveDataTable"; +import { FileUpload } from "./widgets/FileUpload"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { useParams } from "next/navigation"; import { screenApi } from "@/lib/api/screen"; @@ -56,7 +58,7 @@ export const InteractiveScreenViewer: React.FC = ( hideLabel = false, screenInfo, }) => { - const { userName } = useAuth(); // 현재 로그인한 사용자명 가져오기 + const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기 const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); @@ -1499,6 +1501,86 @@ export const InteractiveScreenViewer: React.FC = ( } }; + // 파일 첨부 컴포넌트 처리 + if (component.type === "file") { + const fileComponent = component as FileComponent; + + console.log("🎯 File 컴포넌트 렌더링:", { + componentId: fileComponent.id, + currentUploadedFiles: fileComponent.uploadedFiles?.length || 0, + hasOnFormDataChange: !!onFormDataChange, + userInfo: user ? { userId: user.userId, companyCode: user.companyCode } : "no user" + }); + + const handleFileUpdate = useCallback(async (updates: Partial) => { + // 실제 화면에서는 파일 업데이트를 처리 + console.log("📎 InteractiveScreenViewer - 파일 컴포넌트 업데이트:", { + updates, + hasUploadedFiles: !!updates.uploadedFiles, + uploadedFilesCount: updates.uploadedFiles?.length || 0, + hasOnFormDataChange: !!onFormDataChange + }); + + if (updates.uploadedFiles && onFormDataChange) { + const fieldName = fileComponent.columnName || fileComponent.id; + + // attach_file_info 테이블 구조에 맞는 데이터 생성 + const fileInfoForDB = updates.uploadedFiles.map(file => ({ + objid: file.objid.replace('temp_', ''), // temp_ 제거 + target_objid: "", + saved_file_name: file.savedFileName, + real_file_name: file.realFileName, + doc_type: file.docType, + doc_type_name: file.docTypeName, + file_size: file.fileSize, + file_ext: file.fileExt, + file_path: file.filePath, + writer: file.writer, + regdate: file.regdate, + status: file.status, + parent_target_objid: "", + company_code: file.companyCode + })); + + console.log("💾 attach_file_info 형태로 변환된 데이터:", fileInfoForDB); + + // FormData에는 파일 연결 정보만 저장 (간단한 형태) + const formDataValue = { + fileCount: updates.uploadedFiles.length, + docType: fileComponent.fileConfig.docType, + files: updates.uploadedFiles.map(file => ({ + objid: file.objid, + realFileName: file.realFileName, + fileSize: file.fileSize, + status: file.status + })) + }; + + console.log("📝 FormData 저장값:", { fieldName, formDataValue }); + onFormDataChange(fieldName, formDataValue); + + // TODO: 실제 API 연동 시 attach_file_info 테이블에 저장 + // await saveFilesToDatabase(fileInfoForDB); + + } else { + console.warn("⚠️ 파일 업데이트 실패:", { + hasUploadedFiles: !!updates.uploadedFiles, + hasOnFormDataChange: !!onFormDataChange + }); + } + }, [fileComponent, onFormDataChange]); + + return ( +
+ +
+ ); + } + // 그룹 컴포넌트 처리 if (component.type === "group") { const children = allComponents.filter((comp) => comp.parentId === component.id); diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 5a348eb3..ee00bdef 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -5,6 +5,7 @@ import { ComponentData, WebType, WidgetComponent, + FileComponent, DateTypeConfig, NumberTypeConfig, SelectTypeConfig, @@ -24,6 +25,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { FileUpload } from "./widgets/FileUpload"; +import { useAuth } from "@/hooks/useAuth"; // import { Checkbox } from "@/components/ui/checkbox"; // import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { @@ -850,6 +853,7 @@ export const RealtimePreview: React.FC = ({ children, onGroupToggle, }) => { + const { user } = useAuth(); // 사용자 정보 가져오기 const { type, label, tableName, size, style } = component; // 위젯 컴포넌트인 경우에만 columnName과 widgetType 접근 @@ -1364,6 +1368,22 @@ export const RealtimePreview: React.FC = ({
{renderWidget(component)}
)} + + {type === "file" && ( +
+ {/* 파일 첨부 컴포넌트 */} +
+ { + // 미리보기에서는 업데이트 비활성화 + console.log("파일 컴포넌트 업데이트 (미리보기에서는 비활성화)"); + }} + userInfo={user} // 사용자 정보 전달 + /> +
+
+ )} ); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 3e77db90..674f29be 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -991,6 +991,57 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...templateComp.style, }, } as ComponentData; + } else if (templateComp.type === "file") { + // 파일 첨부 컴포넌트 생성 + const gridColumns = 6; // 기본값: 6컬럼 + + const calculatedSize = + currentGridInfo && layout.gridSettings?.snapToGrid + ? (() => { + const newWidth = calculateWidthFromColumns( + gridColumns, + currentGridInfo, + layout.gridSettings as GridUtilSettings, + ); + return { + width: newWidth, + height: templateComp.size.height, + }; + })() + : templateComp.size; + + return { + id: componentId, + type: "file", + label: templateComp.label, + position: finalPosition, + size: calculatedSize, + gridColumns, + fileConfig: { + accept: ["image/*", ".pdf", ".doc", ".docx", ".xls", ".xlsx"], + multiple: true, + maxSize: 10, // 10MB + maxFiles: 5, + docType: "DOCUMENT", + docTypeName: "일반 문서", + targetObjid: selectedScreen?.screenId || "", + showPreview: true, + showProgress: true, + dragDropText: "파일을 드래그하여 업로드하세요", + uploadButtonText: "파일 선택", + autoUpload: true, + chunkedUpload: false, + }, + uploadedFiles: [], + style: { + labelDisplay: true, + labelFontSize: "14px", + labelColor: "#374151", + labelFontWeight: "600", + labelMarginBottom: "8px", + ...templateComp.style, + }, + } as ComponentData; } else { // 위젯 컴포넌트 const widgetType = templateComp.widgetType || "text"; diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 193e5bfb..8f3ee7ed 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { ComponentData, WidgetComponent, + FileComponent, WebTypeConfig, DateTypeConfig, NumberTypeConfig, @@ -30,6 +31,7 @@ import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel"; import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel"; import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel"; import { ButtonConfigPanel } from "./ButtonConfigPanel"; +import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; interface DetailSettingsPanelProps { selectedComponent?: ComponentData; @@ -214,13 +216,13 @@ export const DetailSettingsPanel: React.FC = ({ select ); } - if (selectedComponent.type !== "widget") { + if (selectedComponent.type !== "widget" && selectedComponent.type !== "file") { return (
-

위젯 컴포넌트가 아닙니다

+

설정할 수 없는 컴포넌트입니다

- 상세 설정은 위젯 컴포넌트에서만 사용할 수 있습니다. + 상세 설정은 위젯 컴포넌트와 파일 컴포넌트에서만 사용할 수 있습니다.
현재 선택된 컴포넌트: {selectedComponent.type}

@@ -228,6 +230,33 @@ export const DetailSettingsPanel: React.FC = ({ select ); } + // 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링 + if (selectedComponent.type === "file") { + const fileComponent = selectedComponent as FileComponent; + + return ( +
+ {/* 헤더 */} +
+
+ +

파일 컴포넌트 설정

+
+
+ 타입: + 파일 업로드 +
+
문서 타입: {fileComponent.fileConfig.docTypeName}
+
+ + {/* 파일 컴포넌트 설정 영역 */} +
+ +
+
+ ); + } + const widget = selectedComponent as WidgetComponent; return ( diff --git a/frontend/components/screen/panels/FileComponentConfigPanel.tsx b/frontend/components/screen/panels/FileComponentConfigPanel.tsx new file mode 100644 index 00000000..827069c3 --- /dev/null +++ b/frontend/components/screen/panels/FileComponentConfigPanel.tsx @@ -0,0 +1,338 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { FileComponent } from "@/types/screen"; +import { Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface FileComponentConfigPanelProps { + component: FileComponent; + onUpdateProperty: (componentId: string, path: string, value: any) => void; +} + +export const FileComponentConfigPanel: React.FC = ({ component, onUpdateProperty }) => { + // 로컬 상태 + const [localInputs, setLocalInputs] = useState({ + docType: component.fileConfig.docType || "DOCUMENT", + docTypeName: component.fileConfig.docTypeName || "일반 문서", + dragDropText: component.fileConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요", + maxSize: component.fileConfig.maxSize || 10, + maxFiles: component.fileConfig.maxFiles || 5, + newAcceptType: "", // 새 파일 타입 추가용 + }); + + const [localValues, setLocalValues] = useState({ + multiple: component.fileConfig.multiple ?? true, + showPreview: component.fileConfig.showPreview ?? true, + showProgress: component.fileConfig.showProgress ?? true, + }); + + const [acceptTypes, setAcceptTypes] = useState(component.fileConfig.accept || []); + + // 컴포넌트 변경 시 로컬 상태 동기화 + useEffect(() => { + setLocalInputs({ + docType: component.fileConfig.docType || "DOCUMENT", + docTypeName: component.fileConfig.docTypeName || "일반 문서", + dragDropText: component.fileConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요", + maxSize: component.fileConfig.maxSize || 10, + maxFiles: component.fileConfig.maxFiles || 5, + newAcceptType: "", + }); + + setLocalValues({ + multiple: component.fileConfig.multiple ?? true, + showPreview: component.fileConfig.showPreview ?? true, + showProgress: component.fileConfig.showProgress ?? true, + }); + + setAcceptTypes(component.fileConfig.accept || []); + }, [component.fileConfig]); + + // 미리 정의된 문서 타입들 + const docTypeOptions = [ + { value: "CONTRACT", label: "계약서" }, + { value: "DRAWING", label: "도면" }, + { value: "PHOTO", label: "사진" }, + { value: "DOCUMENT", label: "일반 문서" }, + { value: "REPORT", label: "보고서" }, + { value: "SPECIFICATION", label: "사양서" }, + { value: "MANUAL", label: "매뉴얼" }, + { value: "CERTIFICATE", label: "인증서" }, + { value: "OTHER", label: "기타" }, + ]; + + // 미리 정의된 파일 타입들 + const commonFileTypes = [ + { value: "image/*", label: "모든 이미지 파일" }, + { value: ".pdf", label: "PDF 파일" }, + { value: ".doc,.docx", label: "Word 문서" }, + { value: ".xls,.xlsx", label: "Excel 파일" }, + { value: ".ppt,.pptx", label: "PowerPoint 파일" }, + { value: ".txt", label: "텍스트 파일" }, + { value: ".zip,.rar", label: "압축 파일" }, + { value: ".dwg,.dxf", label: "CAD 파일" }, + ]; + + // 파일 타입 추가 + const addAcceptType = useCallback(() => { + const newType = localInputs.newAcceptType.trim(); + if (newType && !acceptTypes.includes(newType)) { + const newAcceptTypes = [...acceptTypes, newType]; + setAcceptTypes(newAcceptTypes); + onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes); + setLocalInputs((prev) => ({ ...prev, newAcceptType: "" })); + } + }, [localInputs.newAcceptType, acceptTypes, component.id, onUpdateProperty]); + + // 파일 타입 제거 + const removeAcceptType = useCallback( + (typeToRemove: string) => { + const newAcceptTypes = acceptTypes.filter((type) => type !== typeToRemove); + setAcceptTypes(newAcceptTypes); + onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes); + }, + [acceptTypes, component.id, onUpdateProperty], + ); + + // 미리 정의된 파일 타입 추가 + const addCommonFileType = useCallback( + (fileType: string) => { + const types = fileType.split(","); + const newAcceptTypes = [...acceptTypes]; + + types.forEach((type) => { + if (!newAcceptTypes.includes(type.trim())) { + newAcceptTypes.push(type.trim()); + } + }); + + setAcceptTypes(newAcceptTypes); + onUpdateProperty(component.id, "fileConfig.accept", newAcceptTypes); + }, + [acceptTypes, component.id, onUpdateProperty], + ); + + return ( +
+ {/* 문서 분류 설정 */} +
+

문서 분류 설정

+ +
+ + +
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, docTypeName: newValue })); + onUpdateProperty(component.id, "fileConfig.docTypeName", newValue); + }} + placeholder="문서 타입 표시명" + /> +
+
+ + {/* 파일 업로드 제한 설정 */} +
+

파일 업로드 제한

+ +
+
+ + { + const newValue = parseInt(e.target.value) || 10; + setLocalInputs((prev) => ({ ...prev, maxSize: newValue })); + onUpdateProperty(component.id, "fileConfig.maxSize", newValue); + }} + /> +
+ +
+ + { + const newValue = parseInt(e.target.value) || 5; + setLocalInputs((prev) => ({ ...prev, maxFiles: newValue })); + onUpdateProperty(component.id, "fileConfig.maxFiles", newValue); + }} + /> +
+
+ +
+ { + setLocalValues((prev) => ({ ...prev, multiple: checked as boolean })); + onUpdateProperty(component.id, "fileConfig.multiple", checked); + }} + /> + +
+
+ + {/* 허용 파일 타입 설정 */} +
+

허용 파일 타입

+ + {/* 미리 정의된 파일 타입 버튼들 */} +
+ +
+ {commonFileTypes.map((fileType) => ( + + ))} +
+
+ + {/* 현재 설정된 파일 타입들 */} +
+ +
+ {acceptTypes.map((type, index) => ( + + {type} + + + ))} + {acceptTypes.length === 0 && 모든 파일 타입 허용} +
+
+ + {/* 사용자 정의 파일 타입 추가 */} +
+ +
+ { + setLocalInputs((prev) => ({ ...prev, newAcceptType: e.target.value })); + }} + placeholder="예: .dwg, image/*, .custom" + onKeyPress={(e) => { + if (e.key === "Enter") { + addAcceptType(); + } + }} + /> + +
+
+
+ + {/* UI 설정 */} +
+

UI 설정

+ +
+ +