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 afdeb691..af59bceb 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..98430546 --- /dev/null +++ b/backend-node/src/controllers/fileController.ts @@ -0,0 +1,584 @@ +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) => { + // 프론트엔드에서 전송된 accept 정보 확인 + const acceptHeader = req.body?.accept; + console.log("🔍 파일 타입 검증:", { + fileName: file.originalname, + mimeType: file.mimetype, + acceptFromFrontend: acceptHeader, + }); + + // 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용 + if ( + acceptHeader && + (acceptHeader.includes("*/*") || acceptHeader.includes("*")) + ) { + console.log("✅ 와일드카드 허용: 모든 파일 타입 허용"); + cb(null, true); + return; + } + + // 기본 허용 파일 타입 + const defaultAllowedTypes = [ + "image/jpeg", + "image/png", + "image/gif", + "text/html", // HTML 파일 추가 + "text/plain", // 텍스트 파일 추가 + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/zip", // ZIP 파일 추가 + "application/x-zip-compressed", // ZIP 파일 (다른 MIME 타입) + ]; + + if (defaultAllowedTypes.includes(file.mimetype)) { + console.log("✅ 기본 허용 파일 타입:", file.mimetype); + cb(null, true); + } else { + console.log("❌ 허용되지 않는 파일 타입:", file.mimetype); + 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[]; + + // 파라미터 확인 및 로깅 + console.log("📤 파일 업로드 요청 수신:", { + filesCount: files?.length || 0, + bodyKeys: Object.keys(req.body), + fullBody: req.body, // 전체 body 내용 확인 + }); + + const { + docType = "DOCUMENT", + docTypeName = "일반 문서", + targetObjid, + parentTargetObjid, + // 테이블 연결 정보 (새로 추가) + linkedTable, + linkedField, + recordId, + autoLink, + // 가상 파일 컬럼 정보 + columnName, + isVirtualFileColumn, + } = 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"; + + // 자동 연결 로직 - target_objid 자동 생성 + let finalTargetObjid = targetObjid; + if (autoLink === "true" && linkedTable && recordId) { + // 가상 파일 컬럼의 경우 컬럼명도 포함한 target_objid 생성 + if (isVirtualFileColumn === "true" && columnName) { + finalTargetObjid = `${linkedTable}:${recordId}:${columnName}`; + } else { + finalTargetObjid = `${linkedTable}:${recordId}`; + } + + console.log("🔗 자동 연결 활성화:", { + linkedTable, + linkedField, + recordId, + columnName, + isVirtualFileColumn, + generatedTargetObjid: finalTargetObjid, + }); + } + + 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: finalTargetObjid, + 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 getLinkedFiles = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { tableName, recordId } = req.params; + + console.log("📎 연결된 파일 조회 요청:", { + tableName, + recordId, + }); + + // target_objid 생성 (테이블명:레코드ID 형식) + const baseTargetObjid = `${tableName}:${recordId}`; + + console.log("🔍 파일 조회 쿼리:", { + tableName, + recordId, + baseTargetObjid, + queryPattern: `${baseTargetObjid}%`, + }); + + // 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴) + const files = await prisma.attach_file_info.findMany({ + where: { + target_objid: { + startsWith: baseTargetObjid, // tableName:recordId로 시작하는 모든 파일 + }, + status: "ACTIVE", + }, + orderBy: { + regdate: "desc", + }, + }); + + console.log("📁 조회된 파일 목록:", { + foundFiles: files.length, + targetObjids: files.map((f) => f.target_objid), + }); + + 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, + })); + + console.log("✅ 연결된 파일 조회 완료:", { + baseTargetObjid, + fileCount: fileList.length, + }); + + res.json({ + success: true, + files: fileList, + totalCount: fileList.length, + targetObjid: baseTargetObjid, // 기준 target_objid 반환 + }); + } 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..0770b8b2 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -1,497 +1,53 @@ -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, + getLinkedFiles, + 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 GET /api/files/linked/:tableName/:recordId + * @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.get("/linked/:tableName/:recordId", getLinkedFiles); /** - * 이미지 미리보기 - * GET /api/files/preview/:fileId + * @route DELETE /api/files/: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 : "알 수 없는 오류", - }); - } - } -); +router.delete("/:objid", deleteFile); /** - * 파일 정보 조회 - * GET /api/files/info/:fileId + * @route GET /api/files/download/:objid + * @desc 파일 다운로드 + * @access Private */ -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 628fa592..8a6fb753 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -540,6 +540,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 []; + } + } + /** * 테이블 데이터 조회 (페이징 + 검색) */ @@ -565,6 +680,9 @@ export class TableManagementService { logger.info(`테이블 데이터 조회: ${tableName}`, options); + // 🎯 파일 타입 컬럼 감지 + const fileColumns = await this.getFileTypeColumns(tableName); + // WHERE 조건 구성 let whereConditions: string[] = []; let searchValues: any[] = []; @@ -621,13 +739,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..4f9ab6e1 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -35,24 +35,42 @@ import { ZoomIn, ZoomOut, RotateCw, + Folder, + FolderOpen, } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { getCurrentUser, UserInfo } from "@/lib/api/client"; -import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen"; +import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen"; import { cn } from "@/lib/utils"; -import { downloadFile } from "@/lib/api/file"; +import { downloadFile, getLinkedFiles } from "@/lib/api/file"; import { toast } from "sonner"; +import { FileUpload } from "@/components/screen/widgets/FileUpload"; -// 파일 데이터 타입 정의 +// 파일 데이터 타입 정의 (AttachedFileInfo와 호환) interface FileInfo { - id: string; - name: string; - size: number; - type: string; - extension: string; - uploadedAt: string; - lastModified: string; - serverFilename?: string; // 서버에 저장된 파일명 (다운로드용) + // AttachedFileInfo 기본 속성들 + objid: string; + savedFileName: string; + realFileName: string; + fileSize: number; + fileExt: string; + filePath: string; + docType: string; + docTypeName: string; + targetObjid: string; + parentTargetObjid?: string; + companyCode: string; + writer: string; + regdate: string; + status: string; + + // 추가 호환성 속성들 + path?: string; // filePath와 동일 + name?: string; // realFileName과 동일 + id?: string; // objid와 동일 + size?: number; // fileSize와 동일 + type?: string; // docType과 동일 + uploadedAt?: string; // regdate와 동일 } interface FileColumnData { @@ -94,6 +112,112 @@ export const InteractiveDataTable: React.FC = ({ const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); + // 파일 관리 상태 + const [fileStatusMap, setFileStatusMap] = useState>({}); // 행별 파일 상태 + const [showFileManagementModal, setShowFileManagementModal] = useState(false); + const [selectedRowForFiles, setSelectedRowForFiles] = useState | null>(null); + const [selectedColumnForFiles, setSelectedColumnForFiles] = useState(null); // 선택된 컬럼 정보 + const [linkedFiles, setLinkedFiles] = useState([]); + + // 파일 상태 확인 함수 + const checkFileStatus = useCallback( + async (rowData: Record) => { + if (!component.tableName) return; + + // 첫 번째 컬럼을 기본키로 사용 (실제로는 더 정교한 로직 필요) + const primaryKeyField = Object.keys(rowData)[0]; // 임시로 첫 번째 컬럼 사용 + const recordId = rowData[primaryKeyField]; + + if (!recordId) return; + + try { + const response = await getLinkedFiles(component.tableName, recordId); + const hasFiles = response.files && response.files.length > 0; + const fileCount = response.files ? response.files.length : 0; + + return { hasFiles, fileCount, files: response.files || [] }; + } catch (error) { + console.error("파일 상태 확인 오류:", error); + return { hasFiles: false, fileCount: 0, files: [] }; + } + }, + [component.tableName], + ); + + // 파일 폴더 아이콘 클릭 핸들러 (전체 행 파일 관리) + const handleFileIconClick = useCallback( + async (rowData: Record) => { + const fileStatus = await checkFileStatus(rowData); + if (fileStatus) { + setSelectedRowForFiles(rowData); + setLinkedFiles(fileStatus.files); + setShowFileManagementModal(true); + } + }, + [checkFileStatus], + ); + + // 컬럼별 파일 상태 확인 + const checkColumnFileStatus = useCallback( + async (rowData: Record, column: DataTableColumn) => { + if (!component.tableName) return null; + + const primaryKeyField = Object.keys(rowData)[0]; + const recordId = rowData[primaryKeyField]; + if (!recordId) return null; + + try { + // 가상 파일 컬럼의 경우: tableName:recordId:columnName 형태로 target_objid 생성 + const targetObjid = column.isVirtualFileColumn + ? `${component.tableName}:${recordId}:${column.columnName}` + : `${component.tableName}:${recordId}`; + + const response = await getLinkedFiles(component.tableName, recordId); + + // 가상 파일 컬럼의 경우 해당 컬럼의 파일만 필터링 + let files = response.files || []; + if (column.isVirtualFileColumn) { + // 현재 컬럼명으로 먼저 시도 + files = files.filter( + (file: any) => file.targetObjid === targetObjid || file.targetObjid?.endsWith(`:${column.columnName}`), // target_objid → targetObjid + ); + + // 파일이 없는 경우 fallback: 모든 파일 컬럼 패턴 시도 + if (files.length === 0) { + // 해당 테이블:레코드의 모든 파일 컬럼 파일들을 가져옴 + files = (response.files || []).filter( + (file: any) => file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`), // target_objid → targetObjid + ); + } + } + + const hasFiles = files.length > 0; + const fileCount = files.length; + + return { hasFiles, fileCount, files, targetObjid }; + } catch (error) { + console.error("컬럼별 파일 상태 확인 오류:", error); + return { hasFiles: false, fileCount: 0, files: [], targetObjid: null }; + } + }, + [component.tableName], + ); + + // 컬럼별 파일 클릭 핸들러 + const handleColumnFileClick = useCallback( + async (rowData: Record, column: DataTableColumn) => { + // 컬럼별 파일 상태 확인 + const fileStatus = await checkColumnFileStatus(rowData, column); + setSelectedRowForFiles(rowData); + setSelectedColumnForFiles(column); // 선택된 컬럼 정보 저장 + setLinkedFiles(fileStatus?.files || []); + setShowFileManagementModal(true); + + // TODO: 모달에 컬럼 정보 전달하여 해당 컬럼 전용 파일 업로드 가능하게 하기 + }, + [checkColumnFileStatus], + ); + // 이미지 미리보기 핸들러들 const handlePreviewImage = useCallback((fileInfo: FileInfo) => { setPreviewImage(fileInfo); @@ -200,25 +324,92 @@ export const InteractiveDataTable: React.FC = ({ setLoading(true); try { - console.log("🔍 테이블 데이터 조회:", { - tableName: component.tableName, - page, - pageSize, - searchParams, - }); - const result = await tableTypeApi.getTableData(component.tableName, { page, size: pageSize, search: searchParams, }); - console.log("✅ 테이블 데이터 조회 결과:", result); - setData(result.data); setTotal(result.total); setTotalPages(result.totalPages); setCurrentPage(result.page); + + // 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별) + const fileStatusPromises = result.data.map(async (rowData: Record) => { + const primaryKeyField = Object.keys(rowData)[0]; + const recordId = rowData[primaryKeyField]; + + if (!recordId) return { rowKey: recordId, statuses: {} }; + + try { + const fileResponse = await getLinkedFiles(component.tableName, recordId); + const allFiles = fileResponse.files || []; + + // 전체 행에 대한 파일 상태 + const rowStatus = { + hasFiles: allFiles.length > 0, + fileCount: allFiles.length, + }; + + // 가상 파일 컬럼별 파일 상태 + const columnStatuses: Record = {}; + + // 가상 파일 컬럼 찾기 + const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn); + + virtualFileColumns.forEach((column) => { + // 해당 컬럼의 파일만 필터링 (targetObjid로 수정) + let columnFiles = allFiles.filter((file: any) => file.targetObjid?.endsWith(`:${column.columnName}`)); + + // fallback: 컬럼명으로 찾지 못한 경우 모든 파일 컬럼 파일 포함 + if (columnFiles.length === 0) { + columnFiles = allFiles.filter((file: any) => + file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`), + ); + } + + const columnKey = `${recordId}_${column.columnName}`; + columnStatuses[columnKey] = { + hasFiles: columnFiles.length > 0, + fileCount: columnFiles.length, + }; + }); + + return { + rowKey: recordId, + statuses: { + [recordId]: rowStatus, // 전체 행 상태 + ...columnStatuses, // 컬럼별 상태 + }, + }; + } catch { + // 에러 시 기본값 + const defaultStatuses: Record = { + [recordId]: { hasFiles: false, fileCount: 0 }, + }; + + // 가상 파일 컬럼에 대해서도 기본값 설정 + const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn); + virtualFileColumns.forEach((column) => { + const columnKey = `${recordId}_${column.columnName}`; + defaultStatuses[columnKey] = { hasFiles: false, fileCount: 0 }; + }); + + return { rowKey: recordId, statuses: defaultStatuses }; + } + }); + + // 파일 상태 업데이트 + Promise.all(fileStatusPromises).then((results) => { + const statusMap: Record = {}; + + results.forEach((result) => { + Object.assign(statusMap, result.statuses); + }); + + setFileStatusMap(statusMap); + }); } catch (error) { console.error("❌ 테이블 데이터 조회 실패:", error); setData([]); @@ -251,10 +442,8 @@ export const InteractiveDataTable: React.FC = ({ useEffect(() => { const fetchTableColumns = async () => { try { - console.log("🔄 테이블 컬럼 정보 로드 시작:", component.tableName); const columns = await tableTypeApi.getColumns(component.tableName); setTableColumns(columns); - console.log("✅ 테이블 컬럼 정보 로드 완료:", columns); } catch (error) { console.error("테이블 컬럼 정보 로드 실패:", error); } @@ -272,7 +461,6 @@ export const InteractiveDataTable: React.FC = ({ // 검색 실행 const handleSearch = useCallback(() => { - console.log("🔍 검색 실행:", searchValues); loadData(1, searchValues); }, [searchValues, loadData]); @@ -512,8 +700,6 @@ export const InteractiveDataTable: React.FC = ({ } else { handleAddFormChange(columnName, fileNames); } - - console.log("✅ 파일 업로드 완료:", validFiles); } catch (error) { console.error("파일 업로드 실패:", error); alert("파일 업로드에 실패했습니다."); @@ -1280,23 +1466,15 @@ export const InteractiveDataTable: React.FC = ({ } }; - // 파일 모달 열기 - const openFileModal = (fileData: FileColumnData, column: DataTableColumn) => { - setCurrentFileData(fileData); - setCurrentFileColumn(column); - setShowFileModal(true); - }; - // 파일 다운로드 const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => { try { - console.log("📥 파일 다운로드 시작:", fileInfo); + // savedFileName이 없는 경우 파일 경로에서 추출 시도 + const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null); - // serverFilename이 없는 경우 처리 - if (!fileInfo.serverFilename) { + if (!serverFilename) { // _file 속성이 있는 경우 로컬 파일로 다운로드 if ((fileInfo as any)._file) { - console.log("📁 로컬 파일 다운로드 시도:", fileInfo.name); try { const file = (fileInfo as any)._file; @@ -1307,12 +1485,6 @@ export const InteractiveDataTable: React.FC = ({ return; } - console.log("📁 유효한 파일 객체 확인됨:", { - name: file.name || fileInfo.name, - size: file.size, - type: file.type, - }); - const url = URL.createObjectURL(file); const link = document.createElement("a"); link.href = url; @@ -1337,8 +1509,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, }); @@ -1350,56 +1522,53 @@ export const InteractiveDataTable: React.FC = ({ }, []); // 셀 값 포맷팅 - const formatCellValue = (value: any, column: DataTableColumn): React.ReactNode => { - if (value === null || value === undefined) return ""; + const formatCellValue = (value: any, column: DataTableColumn, rowData?: Record): React.ReactNode => { + // 가상 파일 컬럼의 경우 value가 없어도 파일 아이콘을 표시해야 함 + if (!column.isVirtualFileColumn && (value === null || value === undefined)) return ""; - // 디버깅을 위한 로그 추가 - if (column.columnName === "file_path") { - console.log("📊 formatCellValue (file_path 컬럼):", { - columnName: column.columnName, - widgetType: column.widgetType, - value: value, - valueType: typeof value, - fullColumn: column, - }); + // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) + const isFileColumn = + column.widgetType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; + + // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) + if (isFileColumn && rowData) { + // 현재 행의 기본키 값 가져오기 + const primaryKeyField = Object.keys(rowData)[0]; + const recordId = rowData[primaryKeyField]; + + // 해당 컬럼에 대한 파일 상태 확인 + const columnFileKey = `${recordId}_${column.columnName}`; + const columnFileStatus = fileStatusMap[columnFileKey]; + const hasFiles = columnFileStatus?.hasFiles || false; + const fileCount = columnFileStatus?.fileCount || 0; + + return ( +
+ +
+ ); } - // file_path 컬럼은 강제로 파일 타입으로 처리 (임시 해결책) - const isFileColumn = column.widgetType === "file" || column.columnName === "file_path"; - switch (column.widgetType) { - case "file": - console.log("🗂️ 파일 타입 컬럼 처리 중:", value); - if (value) { - try { - // JSON 문자열이면 파싱 - const fileData = typeof value === "string" ? JSON.parse(value) : value; - 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 파일 없음; - case "date": if (value) { try { @@ -1557,13 +1726,26 @@ export const InteractiveDataTable: React.FC = ({ {column.label} ))} + {/* 기본 파일 컬럼은 가상 파일 컬럼이 있으면 완전히 숨김 */} + {!visibleColumns.some((col) => col.widgetType === "file") && ( + +
+ + 파일 +
+
+ )} {loading ? ( col.widgetType === "file") ? 1 : 0) + } className="h-32 text-center" >
@@ -1586,15 +1768,54 @@ export const InteractiveDataTable: React.FC = ({ )} {visibleColumns.map((column: DataTableColumn) => ( - {formatCellValue(row[column.columnName], column)} + {formatCellValue(row[column.columnName], column, row)} ))} + {/* 기본 파일 셀은 가상 파일 컬럼이 있으면 완전히 숨김 */} + {!visibleColumns.some((col) => col.widgetType === "file") && ( + + {(() => { + const primaryKeyField = Object.keys(row)[0]; + const recordId = row[primaryKeyField]; + const fileStatus = fileStatusMap[recordId]; + const hasFiles = fileStatus?.hasFiles || false; + const fileCount = fileStatus?.fileCount || 0; + + return ( + + ); + })()} + + )} )) ) : ( col.widgetType === "file") ? 1 : 0) + } className="h-32 text-center" >
@@ -1840,14 +2061,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 +2114,6 @@ export const InteractiveDataTable: React.FC = ({ {" "} {(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB -
- 최종 수정: - {" "} - {new Date(currentFileData.lastModified).toLocaleString("ko-KR")} -
)} @@ -1983,7 +2196,7 @@ export const InteractiveDataTable: React.FC = ({
{previewImage && ( {previewImage.name} = ({ )} + + {/* 파일 관리 모달 */} + + + + + + 파일 관리 + {selectedRowForFiles && ( + + {Object.keys(selectedRowForFiles)[0]}: {selectedRowForFiles[Object.keys(selectedRowForFiles)[0]]} + + )} + + + {linkedFiles.length > 0 + ? `${linkedFiles.length}개의 파일이 연결되어 있습니다.` + : "연결된 파일이 없습니다. 새 파일을 업로드하세요."} + + + +
+ {/* 기존 파일 목록 */} + {linkedFiles.length > 0 && ( +
+

연결된 파일

+ {linkedFiles.map((file: any, index: number) => ( +
+
+ +
+
{file.realFileName}
+
+ {(Number(file.fileSize) / 1024 / 1024).toFixed(2)} MB • {file.docTypeName} + {file.regdate && • {new Date(file.regdate).toLocaleString("ko-KR")}} + {file.writer && • {file.writer}} +
+
+
+
+ {file.fileExt && ["jpg", "jpeg", "png", "gif"].includes(file.fileExt.toLowerCase()) && ( + + )} + +
+
+ ))} +
+ )} + + {/* 파일 업로드 섹션 */} +
+
+

{selectedColumnForFiles?.label || "파일"} 업로드

+ {selectedColumnForFiles?.isVirtualFileColumn && ( + + {selectedColumnForFiles.fileColumnConfig?.docTypeName || "문서"} + + )} +
+ + {selectedRowForFiles && selectedColumnForFiles && component.tableName && ( +
+ { + // 모달에서는 컴포넌트 업데이트가 필요 없으므로 빈 함수 제공 + }} + onFileUpload={async () => { + // 파일 업로드 완료 후 연결된 파일 목록 새로고침 + if (selectedRowForFiles && selectedColumnForFiles) { + const result = await checkColumnFileStatus(selectedRowForFiles, selectedColumnForFiles); + if (result) { + setLinkedFiles(result.files); + + // 파일 상태 맵도 업데이트 + const primaryKeyField = Object.keys(selectedRowForFiles)[0]; + const recordId = selectedRowForFiles[primaryKeyField]; + const columnFileKey = `${recordId}_${selectedColumnForFiles.columnName}`; + + setFileStatusMap((prev) => { + const newFileStatusMap = { + ...prev, + [columnFileKey]: { + hasFiles: result.hasFiles, + fileCount: result.fileCount, + }, + }; + return newFileStatusMap; + }); + + // 전체 테이블의 해당 컬럼 파일 상태도 강제 새로고침 + setTimeout(() => { + // 테이블 데이터 새로고침을 위해 loadData 호출 + if (data && data.length > 0) { + // 현재 데이터를 그대로 사용하되 파일 상태만 새로고침 + const refreshPromises = data.map(async (row) => { + const pk = Object.keys(row)[0]; + const rowId = row[pk]; + const fileKey = `${rowId}_${selectedColumnForFiles.columnName}`; + + const columnStatus = await checkColumnFileStatus(row, selectedColumnForFiles); + if (columnStatus) { + setFileStatusMap((prev) => ({ + ...prev, + [fileKey]: { + hasFiles: columnStatus.hasFiles, + fileCount: columnStatus.fileCount, + }, + })); + } + }); + + Promise.all(refreshPromises); + } + }, 100); + } + } + }} + /> +
+ )} +
+
+ + + + +
+
); }; 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..242a1ca2 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"; @@ -2959,6 +3010,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onUpdateProperty={(componentId: string, path: string, value: any) => { updateComponentProperty(componentId, path, value); }} + currentTable={tables.length > 0 ? tables[0] : undefined} + currentTableName={selectedScreen?.tableName} /> diff --git a/frontend/components/screen/panels/DataTableConfigPanel.tsx b/frontend/components/screen/panels/DataTableConfigPanel.tsx index 95f9cd46..b2c7eb79 100644 --- a/frontend/components/screen/panels/DataTableConfigPanel.tsx +++ b/frontend/components/screen/panels/DataTableConfigPanel.tsx @@ -1061,6 +1061,71 @@ export const DataTableConfigPanel: React.FC = ({ [selectedTable, component.columns, component.filters, onUpdateComponent], ); + // 가상 파일 컬럼 추가 + const addVirtualFileColumn = useCallback(() => { + const fileColumnCount = component.columns.filter((col) => col.isVirtualFileColumn).length; + const newColumnName = `file_column_${fileColumnCount + 1}`; // 순차적 번호 사용 + + const newColumn: DataTableColumn = { + id: generateComponentId(), + columnName: newColumnName, + label: `파일 컬럼 ${fileColumnCount + 1}`, + widgetType: "file", + gridColumns: 2, + visible: true, + filterable: false, // 파일 컬럼은 필터링 불가 + sortable: false, // 파일 컬럼은 정렬 불가 + searchable: false, // 파일 컬럼은 검색 불가 + isVirtualFileColumn: true, // 가상 파일 컬럼 표시 + fileColumnConfig: { + docType: "DOCUMENT", + docTypeName: "일반 문서", + maxFiles: 5, + accept: ["*/*"], + }, + }; + + console.log("📁 가상 파일 컬럼 추가:", { + columnName: newColumn.columnName, + label: newColumn.label, + isVirtualFileColumn: newColumn.isVirtualFileColumn, + }); + + // 로컬 상태에 새 컬럼 입력값 추가 + setLocalColumnInputs((prev) => ({ + ...prev, + [newColumn.id]: newColumn.label, + })); + + // 로컬 체크박스 상태에 새 컬럼 추가 + setLocalColumnCheckboxes((prev) => ({ + ...prev, + [newColumn.id]: { + visible: newColumn.visible, + sortable: newColumn.sortable, + searchable: newColumn.searchable, + }, + })); + + // 로컬 그리드 컬럼 상태에 새 컬럼 추가 + setLocalColumnGridColumns((prev) => ({ + ...prev, + [newColumn.id]: newColumn.gridColumns, + })); + + // 컬럼 업데이트 + const updates: Partial = { + columns: [...component.columns, newColumn], + }; + + onUpdateComponent(updates); + + // 컬럼 추가 후 컬럼 탭으로 자동 이동 + setActiveTab("columns"); + + console.log("✅ 가상 파일 컬럼 추가 완료"); + }, [component.columns, onUpdateComponent]); + return (
@@ -1459,6 +1524,14 @@ export const DataTableConfigPanel: React.FC = ({

테이블 컬럼 설정

{component.columns.length}개 + + {/* 파일 컬럼 추가 버튼 */} + + + {/* 기존 DB 컬럼 추가 */} {selectedTable && (() => { const availableColumns = selectedTable.columns.filter( @@ -1468,7 +1541,7 @@ export const DataTableConfigPanel: React.FC = ({ return availableColumns.length > 0 ? ( { + setLocalInputs((prev) => ({ ...prev, docType: value })); + onUpdateProperty(component.id, "fileConfig.docType", value); + + // 문서 타입 변경 시 자동으로 타입명도 업데이트 + const selectedOption = docTypeOptions.find((opt) => opt.value === value); + if (selectedOption) { + setLocalInputs((prev) => ({ ...prev, docTypeName: selectedOption.label })); + onUpdateProperty(component.id, "fileConfig.docTypeName", selectedOption.label); + } + }} + > + + + + + {docTypeOptions.map((option) => ( + + {option.label} + + ))} + + +
+ +
+ + { + 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 설정

+ +
+ +