파일 컴포넌트 분리 #23

Merged
kjs merged 1 commits from feature/screen-management into dev 2025-09-05 21:52:43 +09:00
21 changed files with 1999 additions and 556 deletions

View File

@ -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);
});

View File

@ -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 {

View File

@ -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: "사용자 정보 조회 성공",

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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개 파일

View File

@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;

View File

@ -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) {

View File

@ -529,6 +529,121 @@ export class TableManagementService {
}
}
/**
* (attach_file_info에서 )
*/
private async enrichFileData(
data: any[],
fileColumns: string[],
tableName: string
): Promise<any[]> {
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<any | null> {
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<string[]> {
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<any[]>(
let data = await prisma.$queryRawUnsafe<any[]>(
dataQuery,
...searchValues,
size,
offset
);
// 🎯 파일 컬럼이 있으면 파일 정보 보강
if (fileColumns.length > 0) {
data = await this.enrichFileData(data, fileColumns, safeTableName);
}
const totalPages = Math.ceil(total / size);
logger.info(

View File

@ -1292,8 +1292,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
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<InteractiveDataTableProps> = ({
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<InteractiveDataTableProps> = ({
// 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 (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-blue-600 hover:bg-blue-50 hover:text-blue-800"
onClick={() => openFileModal(fileData, column)}
>
<File className="mr-1 h-4 w-4" />
{fileData.totalCount === 1 ? "파일 1개" : `파일 ${fileData.totalCount}`}
</Button>
<Badge variant="secondary" className="text-xs">
{(fileData.totalSize / 1024 / 1024).toFixed(1)}MB
</Badge>
</div>
);
// regdate가 없는 경우 기본값 설정
if (!fileData.regdate) {
fileData.regdate = new Date().toISOString();
}
} catch (error) {
console.warn("파일 데이터 파싱 오류:", error);
}
}
return <span className="text-sm text-gray-400 italic"> </span>;
// 개별 파일들에도 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 (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-blue-600 hover:bg-blue-50 hover:text-blue-800"
onClick={() => openFileModal(fileData, column)}
>
<File className="mr-1 h-4 w-4" />
{fileData.totalCount === 1 ? "파일 1개" : `파일 ${fileData.totalCount}`}
</Button>
<Badge variant="secondary" className="text-xs">
{(fileData.totalSize / 1024 / 1024).toFixed(1)}MB
</Badge>
</div>
);
}
} catch (error) {
console.warn("파일 데이터 파싱 오류:", error);
}
}
return <span className="text-sm text-gray-400 italic"> </span>;
}
switch (column.widgetType) {
case "date":
if (value) {
try {
@ -1840,14 +1897,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<span>: {fileInfo.type || "알 수 없음"}</span>
</div>
<div className="flex items-center gap-4">
<span>: {fileInfo.extension || "N/A"}</span>
<span>: {new Date(fileInfo.uploadedAt).toLocaleString("ko-KR")}</span>
{fileInfo.regdate && (
<span>: {new Date(fileInfo.regdate).toLocaleString("ko-KR")}</span>
)}
{fileInfo.writer && <span>: {fileInfo.writer}</span>}
</div>
{fileInfo.lastModified && (
<div>
<span>: {new Date(fileInfo.lastModified).toLocaleString("ko-KR")}</span>
</div>
)}
</div>
</div>
@ -1896,11 +1950,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{" "}
{(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB
</div>
<div className="col-span-2">
<span className="font-medium"> :</span>
{" "}
{new Date(currentFileData.lastModified).toLocaleString("ko-KR")}
</div>
</div>
</div>
)}

View File

@ -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<InteractiveScreenViewerProps> = (
hideLabel = false,
screenInfo,
}) => {
const { userName } = useAuth(); // 현재 로그인한 사용자명 가져오기
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
@ -1499,6 +1501,86 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
};
// 파일 첨부 컴포넌트 처리
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<FileComponent>) => {
// 실제 화면에서는 파일 업데이트를 처리
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 (
<div className="h-full w-full">
<FileUpload
component={fileComponent}
onUpdateComponent={handleFileUpdate}
userInfo={user} // 사용자 정보를 프롭으로 전달
/>
</div>
);
}
// 그룹 컴포넌트 처리
if (component.type === "group") {
const children = allComponents.filter((comp) => comp.parentId === component.id);

View File

@ -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<RealtimePreviewProps> = ({
children,
onGroupToggle,
}) => {
const { user } = useAuth(); // 사용자 정보 가져오기
const { type, label, tableName, size, style } = component;
// 위젯 컴포넌트인 경우에만 columnName과 widgetType 접근
@ -1364,6 +1368,22 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
</div>
)}
{type === "file" && (
<div className="flex h-full flex-col">
{/* 파일 첨부 컴포넌트 */}
<div className="pointer-events-none flex-1">
<FileUpload
component={component as FileComponent}
onUpdateComponent={() => {
// 미리보기에서는 업데이트 비활성화
console.log("파일 컴포넌트 업데이트 (미리보기에서는 비활성화)");
}}
userInfo={user} // 사용자 정보 전달
/>
</div>
</div>
)}
</div>
</div>
);

View File

@ -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";

View File

@ -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<DetailSettingsPanelProps> = ({ select
);
}
if (selectedComponent.type !== "widget") {
if (selectedComponent.type !== "widget" && selectedComponent.type !== "file") {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500">
.
.
<br />
: {selectedComponent.type}
</p>
@ -228,6 +230,33 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
);
}
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
if (selectedComponent.type === "file") {
const fileComponent = selectedComponent as FileComponent;
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800"> </span>
</div>
<div className="mt-1 text-xs text-gray-500"> : {fileComponent.fileConfig.docTypeName}</div>
</div>
{/* 파일 컴포넌트 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4">
<FileComponentConfigPanel component={fileComponent} onUpdateProperty={onUpdateProperty} />
</div>
</div>
);
}
const widget = selectedComponent as WidgetComponent;
return (

View File

@ -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<FileComponentConfigPanelProps> = ({ 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<string[]>(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 (
<div className="space-y-6">
{/* 문서 분류 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="space-y-2">
<Label htmlFor="docType"> </Label>
<Select
value={localInputs.docType}
onValueChange={(value) => {
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);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="문서 타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{docTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="docTypeName"> </Label>
<Input
id="docTypeName"
value={localInputs.docTypeName}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, docTypeName: newValue }));
onUpdateProperty(component.id, "fileConfig.docTypeName", newValue);
}}
placeholder="문서 타입 표시명"
/>
</div>
</div>
{/* 파일 업로드 제한 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxSize"> (MB)</Label>
<Input
id="maxSize"
type="number"
min="1"
max="100"
value={localInputs.maxSize}
onChange={(e) => {
const newValue = parseInt(e.target.value) || 10;
setLocalInputs((prev) => ({ ...prev, maxSize: newValue }));
onUpdateProperty(component.id, "fileConfig.maxSize", newValue);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxFiles"> </Label>
<Input
id="maxFiles"
type="number"
min="1"
max="20"
value={localInputs.maxFiles}
onChange={(e) => {
const newValue = parseInt(e.target.value) || 5;
setLocalInputs((prev) => ({ ...prev, maxFiles: newValue }));
onUpdateProperty(component.id, "fileConfig.maxFiles", newValue);
}}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, multiple: checked as boolean }));
onUpdateProperty(component.id, "fileConfig.multiple", checked);
}}
/>
<Label htmlFor="multiple"> </Label>
</div>
</div>
{/* 허용 파일 타입 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900"> </h4>
{/* 미리 정의된 파일 타입 버튼들 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex flex-wrap gap-2">
{commonFileTypes.map((fileType) => (
<Button
key={fileType.value}
variant="outline"
size="sm"
onClick={() => addCommonFileType(fileType.value)}
className="text-xs"
>
<Plus className="mr-1 h-3 w-3" />
{fileType.label}
</Button>
))}
</div>
</div>
{/* 현재 설정된 파일 타입들 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex flex-wrap gap-2">
{acceptTypes.map((type, index) => (
<Badge key={index} variant="secondary" className="flex items-center space-x-1">
<span>{type}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeAcceptType(type)}
className="h-4 w-4 p-0 hover:bg-transparent"
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
{acceptTypes.length === 0 && <span className="text-sm text-gray-500"> </span>}
</div>
</div>
{/* 사용자 정의 파일 타입 추가 */}
<div className="space-y-2">
<Label htmlFor="newAcceptType"> </Label>
<div className="flex space-x-2">
<Input
id="newAcceptType"
value={localInputs.newAcceptType}
onChange={(e) => {
setLocalInputs((prev) => ({ ...prev, newAcceptType: e.target.value }));
}}
placeholder="예: .dwg, image/*, .custom"
onKeyPress={(e) => {
if (e.key === "Enter") {
addAcceptType();
}
}}
/>
<Button onClick={addAcceptType} disabled={!localInputs.newAcceptType.trim()} size="sm">
</Button>
</div>
</div>
</div>
{/* UI 설정 */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-900">UI </h4>
<div className="space-y-2">
<Label htmlFor="dragDropText"> </Label>
<Textarea
id="dragDropText"
value={localInputs.dragDropText}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, dragDropText: newValue }));
onUpdateProperty(component.id, "fileConfig.dragDropText", newValue);
}}
placeholder="파일을 드래그하거나 클릭하여 업로드하세요"
rows={2}
/>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="showPreview"
checked={localValues.showPreview}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, showPreview: checked as boolean }));
onUpdateProperty(component.id, "fileConfig.showPreview", checked);
}}
/>
<Label htmlFor="showPreview"> </Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showProgress"
checked={localValues.showProgress}
onCheckedChange={(checked) => {
setLocalValues((prev) => ({ ...prev, showProgress: checked as boolean }));
onUpdateProperty(component.id, "fileConfig.showProgress", checked);
}}
/>
<Label htmlFor="showProgress"> </Label>
</div>
</div>
</div>
</div>
);
};

View File

@ -22,6 +22,7 @@ import {
ExternalLink,
MousePointer,
Settings,
Upload,
} from "lucide-react";
// 템플릿 컴포넌트 타입 정의
@ -29,11 +30,11 @@ export interface TemplateComponent {
id: string;
name: string;
description: string;
category: "table" | "button" | "form" | "layout" | "chart" | "status";
category: "table" | "button" | "form" | "layout" | "chart" | "status" | "file";
icon: React.ReactNode;
defaultSize: { width: number; height: number };
components: Array<{
type: "widget" | "container";
type: "widget" | "container" | "datatable" | "file";
widgetType?: string;
label: string;
placeholder?: string;
@ -98,6 +99,30 @@ const templateComponents: TemplateComponent[] = [
},
],
},
// 파일 첨부 템플릿
{
id: "file-upload",
name: "파일 첨부",
description: "파일 업로드, 미리보기, 다운로드가 가능한 파일 첨부 컴포넌트",
category: "file",
icon: <Upload className="h-4 w-4" />,
defaultSize: { width: 600, height: 300 },
components: [
{
type: "file",
label: "파일 첨부",
position: { x: 0, y: 0 },
size: { width: 600, height: 300 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "16px",
},
},
],
},
];
interface TemplatesPanelProps {
@ -111,6 +136,8 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
const categories = [
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
{ id: "button", name: "버튼", icon: <MousePointer className="h-4 w-4" /> },
{ id: "file", name: "파일", icon: <Upload className="h-4 w-4" /> },
];
const filteredTemplates = templateComponents.filter((template) => {

View File

@ -0,0 +1,608 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import { Upload, X, File, Image, Eye, Download, AlertCircle, CheckCircle, Loader2 } from "lucide-react";
import { FileComponent, AttachedFileInfo } from "@/types/screen";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
interface FileUploadProps {
component: FileComponent;
onUpdateComponent: (updates: Partial<FileComponent>) => void;
userInfo?: any; // 사용자 정보 (선택적)
}
/**
* File
* attach_file_info
*/
export function FileUpload({ component, onUpdateComponent, userInfo }: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
const [localUploadedFiles, setLocalUploadedFiles] = useState<AttachedFileInfo[]>(component.uploadedFiles || []);
const fileInputRef = useRef<HTMLInputElement>(null);
const { fileConfig } = component;
const { user: authUser, isLoading, isLoggedIn } = useAuth(); // 인증 상태도 함께 가져오기
// props로 받은 userInfo를 우선 사용, 없으면 useAuth에서 가져온 user 사용
const user = userInfo || authUser;
// 초기화 시점의 사용자 정보를 저장 (타이밍 문제 해결)
const [initialUser, setInitialUser] = useState(user);
// 🎯 최신 사용자 정보를 추적하는 ref (useCallback 내부에서 접근 가능)
const userRef = useRef(user);
// 사용자 정보 디버깅
useEffect(() => {
console.log("👤 File 컴포넌트 인증 상태 및 사용자 정보:", {
isLoading,
isLoggedIn,
hasUser: !!user,
user: user,
userId: user?.userId,
company_code: user?.company_code,
companyCode: user?.companyCode,
userType: typeof user,
userKeys: user ? Object.keys(user) : "no user",
userValues: user ? Object.entries(user) : "no user",
});
// 사용자 정보가 유효하면 initialUser와 userRef 업데이트
if (user && user.userId) {
setInitialUser(user);
userRef.current = user; // 🎯 ref에도 최신 정보 저장
console.log("✅ 초기 사용자 정보 업데이트:", { userId: user.userId, companyCode: user.companyCode });
}
// 회사 관련 필드들 확인
if (user) {
console.log("🔍 회사 관련 필드 검색:", {
company_code: user.company_code,
companyCode: user.companyCode,
company: user.company,
deptCode: user.deptCode,
partnerCd: user.partnerCd,
// 모든 필드에서 company 관련된 것들 찾기
allFields: Object.keys(user).filter(
(key) =>
key.toLowerCase().includes("company") ||
key.toLowerCase().includes("corp") ||
key.toLowerCase().includes("code"),
),
});
} else {
console.warn("⚠️ 사용자 정보가 없습니다. 인증 상태 확인 필요");
}
}, [user, isLoading, isLoggedIn]);
// 컴포넌트 props가 변경될 때 로컬 상태 동기화
useEffect(() => {
console.log("🔄 File 컴포넌트 props 변경:", {
propsUploadedFiles: component.uploadedFiles?.length || 0,
localUploadedFiles: localUploadedFiles.length,
});
setLocalUploadedFiles(component.uploadedFiles || []);
}, [component.uploadedFiles]);
// 실제 사용할 uploadedFiles는 로컬 상태
const uploadedFiles = localUploadedFiles;
// 파일 크기 포맷팅
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// 파일 타입 아이콘 결정
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
return <Image className="h-5 w-5 text-blue-500" />;
}
return <File className="h-5 w-5 text-gray-500" />;
};
// 파일 확장자 검증
const isFileTypeAllowed = (file: File): boolean => {
const fileName = file.name.toLowerCase();
return fileConfig.accept.some((accept) => {
if (accept.startsWith(".")) {
return fileName.endsWith(accept);
}
if (accept.includes("/*")) {
const type = accept.split("/")[0];
return file.type.startsWith(type);
}
return file.type === accept;
});
};
// 파일 선택 핸들러
const handleFileSelect = useCallback(
(files: FileList | null) => {
console.log("📁 파일 선택됨:", files ? Array.from(files).map((f) => f.name) : "없음");
if (!files) return;
const fileArray = Array.from(files);
const validFiles: File[] = [];
const errors: string[] = [];
console.log("🔍 파일 검증 시작:", {
totalFiles: fileArray.length,
currentUploadedCount: uploadedFiles.length,
maxFiles: fileConfig.maxFiles,
maxSize: fileConfig.maxSize,
allowedTypes: fileConfig.accept,
});
// 파일 검증
fileArray.forEach((file) => {
console.log(`📄 파일 검증: ${file.name} (${file.size} bytes, ${file.type})`);
// 파일 타입 검증
if (!isFileTypeAllowed(file)) {
errors.push(`${file.name}: 허용되지 않는 파일 타입입니다.`);
console.log(`❌ 파일 타입 거부: ${file.name}`);
return;
}
// 파일 크기 검증
if (file.size > fileConfig.maxSize * 1024 * 1024) {
errors.push(`${file.name}: 파일 크기가 ${fileConfig.maxSize}MB를 초과합니다.`);
console.log(`❌ 파일 크기 초과: ${file.name} (${file.size} > ${fileConfig.maxSize * 1024 * 1024})`);
return;
}
// 최대 파일 수 검증
if (uploadedFiles.length + validFiles.length >= fileConfig.maxFiles) {
errors.push(`최대 ${fileConfig.maxFiles}개까지만 업로드할 수 있습니다.`);
console.log(`❌ 최대 파일 수 초과`);
return;
}
validFiles.push(file);
console.log(`✅ 파일 검증 통과: ${file.name}`);
});
// 에러가 있으면 알림
if (errors.length > 0) {
console.error("💥 파일 업로드 오류:", errors);
// TODO: Toast 알림 표시
}
// 유효한 파일들을 업로드 큐에 추가
if (validFiles.length > 0) {
console.log(
"✅ 유효한 파일들 업로드 큐에 추가:",
validFiles.map((f) => f.name),
);
setUploadQueue((prev) => [...prev, ...validFiles]);
if (fileConfig.autoUpload) {
console.log("🚀 자동 업로드 시작");
// 자동 업로드 실행
validFiles.forEach(uploadFile);
}
} else {
console.log("❌ 업로드할 유효한 파일이 없음");
}
},
[fileConfig, uploadedFiles.length],
);
// 파일 업로드 함수 (실시간 상태 조회로 타이밍 문제 해결)
const uploadFile = useCallback(
async (file: File) => {
console.log("📤 파일 업로드 시작:", file.name);
const formData = new FormData();
formData.append("files", file);
formData.append("docType", fileConfig.docType);
formData.append("docTypeName", fileConfig.docTypeName);
// 🎯 최신 사용자 정보 참조 (ref를 통해 실시간 값 접근)
const currentUser = userRef.current;
// 실시간 사용자 정보 디버깅
console.log("🔍 FileUpload - uploadFile ref를 통한 실시간 상태:", {
hasCurrentUser: !!currentUser,
currentUser: currentUser
? {
userId: currentUser.userId,
companyCode: currentUser.companyCode,
company_code: currentUser.company_code,
}
: null,
// 기존 상태와 비교
originalUser: user,
originalInitialUser: initialUser,
refExists: !!userRef.current,
});
// 사용자 정보가 로드되지 않은 경우 잠시 대기
if (isLoading) {
console.log("⏳ 사용자 정보 로딩 중... 업로드 대기");
setTimeout(() => uploadFile(file), 500); // 500ms 후 재시도
return;
}
// 사용자 정보가 없는 경우 - 무한루프 방지로 재시도 제한
if (!user && isLoggedIn) {
console.warn("⚠️ 로그인은 되어 있지만 사용자 정보가 없음. DEFAULT로 진행");
// 무한루프 방지: 재시도하지 않고 DEFAULT로 진행
// setTimeout(() => uploadFile(file), 1000); // 1초 후 재시도
// return;
}
// 사용자 정보 추가 (실시간 currentUser 사용으로 타이밍 문제 해결)
const effectiveUser = currentUser || user || initialUser;
const companyCode = effectiveUser?.companyCode || effectiveUser?.company_code || effectiveUser?.deptCode;
if (companyCode) {
// "*"는 실제 회사코드이므로 그대로 사용
formData.append("companyCode", companyCode);
console.log("✅ 회사코드 추가:", companyCode);
} else {
console.warn("⚠️ 회사코드가 없음, DEFAULT 사용. 사용자 정보:", {
user: user,
initialUser: initialUser,
effectiveUser: effectiveUser,
companyCode: effectiveUser?.companyCode,
company_code: effectiveUser?.company_code,
deptCode: effectiveUser?.deptCode,
isLoading,
isLoggedIn,
allUserKeys: effectiveUser ? Object.keys(effectiveUser) : "no user",
});
formData.append("companyCode", "DEFAULT");
}
if (effectiveUser?.userId) {
formData.append("writer", effectiveUser.userId);
console.log("✅ 작성자 추가:", effectiveUser.userId);
} else {
console.warn("⚠️ 사용자ID가 없음, system 사용");
formData.append("writer", "system");
}
// FormData 내용 디버깅
console.log("📋 FormData 내용 확인:");
for (const [key, value] of formData.entries()) {
console.log(` ${key}:`, value);
}
try {
// 업로드 중 상태 표시를 위한 임시 파일 정보 생성
const tempFileInfo: AttachedFileInfo = {
objid: `temp_${Date.now()}`,
savedFileName: "",
realFileName: file.name,
fileSize: file.size,
fileExt: file.name.split(".").pop() || "",
filePath: "",
docType: fileConfig.docType,
docTypeName: fileConfig.docTypeName,
targetObjid: "",
companyCode: "",
writer: "",
regdate: new Date().toISOString(),
status: "UPLOADING",
uploadProgress: 0,
isUploading: true,
};
console.log("📋 임시 파일 정보 생성:", tempFileInfo);
const newUploadedFiles = [...uploadedFiles, tempFileInfo];
console.log("📊 업데이트 전 파일 목록:", uploadedFiles.length, "개");
console.log("📊 업데이트 후 파일 목록:", newUploadedFiles.length, "개");
// 로컬 상태 즉시 업데이트
setLocalUploadedFiles(newUploadedFiles);
// 임시 파일 정보를 업로드된 파일 목록에 추가
console.log("🔄 onUpdateComponent 호출 중...");
onUpdateComponent({
uploadedFiles: newUploadedFiles,
});
console.log("✅ onUpdateComponent 호출 완료");
console.log("🚀 API 호출 시작 - /files/upload");
// 실제 API 호출 (apiClient 사용으로 자동 JWT 토큰 추가)
// FormData 사용 시 Content-Type을 삭제하여 boundary가 자동 설정되도록 함
const response = await apiClient.post("/files/upload", formData, {
headers: {
"Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록
},
});
const result = response.data;
console.log("📡 API 응답 성공:", result);
if (!result.success || !result.files || result.files.length === 0) {
throw new Error(result.message || "파일 업로드 실패");
}
// API 응답에서 실제 파일 정보 받아오기
const uploadedFileInfo = result.files[0]; // 현재는 하나씩 업로드
const successFileInfo: AttachedFileInfo = {
objid: uploadedFileInfo.objid,
savedFileName: uploadedFileInfo.savedFileName,
realFileName: uploadedFileInfo.realFileName,
fileSize: uploadedFileInfo.fileSize,
fileExt: uploadedFileInfo.fileExt,
filePath: uploadedFileInfo.filePath,
docType: uploadedFileInfo.docType,
docTypeName: uploadedFileInfo.docTypeName,
targetObjid: uploadedFileInfo.targetObjid,
parentTargetObjid: uploadedFileInfo.parentTargetObjid,
companyCode: uploadedFileInfo.companyCode,
writer: uploadedFileInfo.writer,
regdate: uploadedFileInfo.regdate,
status: uploadedFileInfo.status,
uploadProgress: 100,
isUploading: false,
};
console.log("✅ 실제 파일 업로드 완료 (attach_file_info 저장됨):", successFileInfo);
const updatedFiles = uploadedFiles.map((f) => (f.objid === tempFileInfo.objid ? successFileInfo : f));
// 로컬 상태 업데이트
setLocalUploadedFiles(updatedFiles);
onUpdateComponent({
uploadedFiles: updatedFiles,
});
// 업로드 큐에서 제거
setUploadQueue((prev) => prev.filter((f) => f !== file));
} catch (error) {
console.error("❌ 파일 업로드 실패:", {
error,
errorMessage: error instanceof Error ? error.message : "알 수 없는 오류",
errorStack: error instanceof Error ? error.stack : undefined,
user: user ? { userId: user.userId, companyCode: user.companyCode, hasUser: true } : "no user",
authState: { isLoading, isLoggedIn },
});
// API 응답 에러인 경우 상세 정보 출력
if ((error as any)?.response) {
console.error("📡 API 응답 에러:", {
status: (error as any).response.status,
statusText: (error as any).response.statusText,
data: (error as any).response.data,
});
}
// 에러 상태로 업데이트
const errorFiles = uploadedFiles.map((f) =>
f.objid === `temp_${file.name}`
? { ...f, hasError: true, errorMessage: "업로드 실패", isUploading: false }
: f,
);
// 로컬 상태 업데이트
setLocalUploadedFiles(errorFiles);
onUpdateComponent({
uploadedFiles: errorFiles,
});
}
},
[fileConfig, uploadedFiles, onUpdateComponent], // ref는 의존성에 포함하지 않음
);
// 파일 삭제
const deleteFile = async (fileInfo: AttachedFileInfo) => {
console.log("🗑️ 파일 삭제:", fileInfo.realFileName);
try {
// 실제 API 호출 (논리적 삭제)
const response = await fetch(`/api/files/${fileInfo.objid}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
writer: fileInfo.writer || "current_user",
}),
});
if (!response.ok) {
throw new Error(`파일 삭제 실패: ${response.status}`);
}
const result = await response.json();
console.log("📡 파일 삭제 API 응답:", result);
if (!result.success) {
throw new Error(result.message || "파일 삭제 실패");
}
const filteredFiles = uploadedFiles.filter((f) => f.objid !== fileInfo.objid);
// 로컬 상태 업데이트
setLocalUploadedFiles(filteredFiles);
onUpdateComponent({
uploadedFiles: filteredFiles,
});
console.log("✅ 파일 삭제 완료 (attach_file_info.status = DELETED)");
} catch (error) {
console.error("파일 삭제 실패:", error);
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
handleFileSelect(e.dataTransfer.files);
},
[handleFileSelect],
);
// 파일 입력 클릭
const handleFileInputClick = () => {
fileInputRef.current?.click();
};
// 파일 미리보기
const previewFile = (fileInfo: AttachedFileInfo) => {
const isImage = ["jpg", "jpeg", "png", "gif", "webp"].includes(fileInfo.fileExt.toLowerCase());
if (isImage) {
// TODO: 이미지 미리보기 모달 열기
console.log("이미지 미리보기:", fileInfo);
} else {
// TODO: 파일 다운로드
console.log("파일 다운로드:", fileInfo);
}
};
return (
<div className="w-full space-y-4">
{/* 드래그 앤 드롭 영역 */}
<div
className={`rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
isDragOver ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<p className="mb-2 text-lg font-medium text-gray-900">
{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}
</p>
<p className="mb-4 text-sm text-gray-500"> </p>
<Button variant="outline" onClick={handleFileInputClick} className="mb-4">
<Upload className="mr-2 h-4 w-4" />
{fileConfig.uploadButtonText || "파일 선택"}
</Button>
<div className="text-xs text-gray-500">
<p> : {fileConfig.accept.join(", ")}</p>
<p>
: {fileConfig.maxSize}MB | : {fileConfig.maxFiles}
</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple={fileConfig.multiple}
accept={fileConfig.accept.join(",")}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
</div>
{/* 업로드된 파일 목록 */}
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900">
({uploadedFiles.length}/{fileConfig.maxFiles})
</h4>
<div className="space-y-2">
{uploadedFiles.map((fileInfo) => (
<div key={fileInfo.objid} className="flex items-center justify-between rounded-lg border bg-gray-50 p-3">
<div className="flex flex-1 items-center space-x-3">
{getFileIcon(fileInfo.fileExt)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">{fileInfo.realFileName}</p>
<div className="flex items-center space-x-2 text-xs text-gray-500">
<span>{formatFileSize(fileInfo.fileSize)}</span>
<span></span>
<span>{fileInfo.fileExt.toUpperCase()}</span>
{fileInfo.writer && (
<>
<span></span>
<span>{fileInfo.writer}</span>
</>
)}
</div>
{/* 업로드 진행률 */}
{fileInfo.isUploading && fileConfig.showProgress && (
<div className="mt-2 h-1 w-full rounded-full bg-gray-200">
<div
className="h-1 rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${fileInfo.uploadProgress || 0}%` }}
/>
</div>
)}
{/* 에러 메시지 */}
{fileInfo.hasError && (
<div className="mt-2 flex items-center space-x-2 rounded-md bg-red-50 p-2 text-sm text-red-700">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{fileInfo.errorMessage}</span>
</div>
)}
</div>
</div>
<div className="flex items-center space-x-1">
{/* 상태 표시 */}
{fileInfo.isUploading && <Loader2 className="h-4 w-4 animate-spin text-blue-500" />}
{fileInfo.status === "ACTIVE" && <CheckCircle className="h-4 w-4 text-green-500" />}
{fileInfo.hasError && <AlertCircle className="h-4 w-4 text-red-500" />}
{/* 액션 버튼 */}
{!fileInfo.isUploading && !fileInfo.hasError && (
<>
{fileConfig.showPreview && (
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
<Download className="h-4 w-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => deleteFile(fileInfo)}
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* 문서 타입 정보 */}
<div className="flex items-center space-x-2">
<Badge variant="outline">{fileConfig.docTypeName}</Badge>
</div>
</div>
);
}

View File

@ -23,7 +23,8 @@ interface UserInfo {
isAdmin: boolean;
sabun?: string;
photo?: string | null;
company_code?: string; // 회사 코드 추가
companyCode?: string; // 백엔드와 일치하도록 수정
company_code?: string; // 하위 호환성을 위해 유지
}
// 인증 상태 타입 정의

View File

@ -95,6 +95,12 @@ apiClient.interceptors.request.use(
console.warn("⚠️ 토큰이 없습니다.");
}
// FormData 요청 시 Content-Type 자동 처리
if (config.data instanceof FormData) {
console.log("📎 FormData 감지 - Content-Type 헤더 제거");
delete config.headers["Content-Type"];
}
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
if (config.method?.toUpperCase() === "GET") {
// 우선순위: 전역 변수 > localStorage > 기본값

View File

@ -36,7 +36,7 @@ export const uploadFiles = async (files: FileList): Promise<FileUploadResponse>
const response = await apiClient.post("/files/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
"Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록
},
});

View File

@ -26,7 +26,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -45,7 +45,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.85.6",
"@tanstack/react-query-devtools": "^5.86.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@ -2461,9 +2461,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.6.tgz",
"integrity": "sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g==",
"version": "5.86.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.86.0.tgz",
"integrity": "sha512-Y6ibQm6BXbw6w1p3a5LrPn8Ae64M0dx7hGmnhrm9P+XAkCCKXOwZN0J5Z1wK/0RdNHtR9o+sWHDXd4veNI60tQ==",
"license": "MIT",
"funding": {
"type": "github",
@ -2471,9 +2471,9 @@
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.84.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz",
"integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==",
"version": "5.86.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.86.0.tgz",
"integrity": "sha512-/JDw9BP80eambEK/EsDMGAcsL2VFT+8F5KCOwierjPU7QP8Wt1GT32yJpn3qOinBM8/zS3Jy36+F0GiyJp411A==",
"dev": true,
"license": "MIT",
"funding": {
@ -2482,12 +2482,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.6.tgz",
"integrity": "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==",
"version": "5.86.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.86.0.tgz",
"integrity": "sha512-jgS/v0oSJkGHucv9zxOS8rL7mjATh1XO3K4eqAV4WMpAly8okcBrGi1YxRZN5S4B59F54x9JFjWrK5vMAvJYqA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.85.6"
"@tanstack/query-core": "5.86.0"
},
"funding": {
"type": "github",
@ -2498,20 +2498,20 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.85.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.6.tgz",
"integrity": "sha512-A6rE39FypFV7eonefk4fxC/vuV/7YJMAcQT94CFAvCpiw65QZX8MOuUpdLBeG1cXajy4Pj8T8sEWHigccntJqg==",
"version": "5.86.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.86.0.tgz",
"integrity": "sha512-+50IcXI+54qHx3IDccbTala4tkToKxa0WKqP4XWlTnP1mQNfHO3dJj8wwnzpG50os69kpSbnU8C98Q/i8b6lyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.84.0"
"@tanstack/query-devtools": "5.86.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-query": "^5.86.0",
"react": "^18 || ^19"
}
},

View File

@ -31,7 +31,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
@ -50,7 +50,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.85.6",
"@tanstack/react-query-devtools": "^5.86.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@ -1,7 +1,7 @@
// 화면관리 시스템 타입 정의
// 기본 컴포넌트 타입
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable";
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable" | "file";
// 웹 타입 정의
export type WebType =
@ -208,6 +208,63 @@ export interface ColumnComponent extends BaseComponent {
children?: string[]; // 자식 컴포넌트 ID 목록
}
// 파일 첨부 컴포넌트
export interface FileComponent extends BaseComponent {
type: "file";
fileConfig: {
// 파일 업로드 설정
accept: string[]; // 허용 파일 타입 ['image/*', '.pdf', '.doc']
multiple: boolean; // 다중 파일 선택 허용
maxSize: number; // 최대 파일 크기 (MB)
maxFiles: number; // 최대 파일 개수
// 문서 분류 설정
docType: string; // 문서 타입 (CONTRACT, DRAWING, PHOTO, DOCUMENT, REPORT, OTHER)
docTypeName: string; // 문서 타입 표시명
// 연결 객체 설정
targetObjid?: string; // 연결된 주 객체 ID (예: 계약 ID, 프로젝트 ID)
parentTargetObjid?: string; // 부모 객체 ID (계층 구조용)
// UI 설정
showPreview: boolean; // 미리보기 표시 여부
showProgress: boolean; // 업로드 진행률 표시
dragDropText: string; // 드래그앤드롭 안내 텍스트
uploadButtonText: string; // 업로드 버튼 텍스트
// 자동 업로드 설정
autoUpload: boolean; // 파일 선택 시 자동 업로드
chunkedUpload: boolean; // 대용량 파일 분할 업로드
};
// 업로드된 파일 목록
uploadedFiles: AttachedFileInfo[];
}
// 첨부파일 정보 (attach_file_info 테이블 기반)
export interface AttachedFileInfo {
objid: string; // 파일 고유 ID
savedFileName: string; // 서버에 저장된 파일명
realFileName: string; // 실제 파일명 (사용자가 본 원본명)
fileSize: number; // 파일 크기 (bytes)
fileExt: string; // 파일 확장자
filePath: string; // 파일 저장 경로
docType: string; // 문서 분류
docTypeName: string; // 문서 분류 표시명
targetObjid: string; // 연결 객체 ID
parentTargetObjid?: string; // 부모 객체 ID
companyCode: string; // 회사 코드
writer: string; // 작성자
regdate: string; // 등록일시
status: string; // 상태 (ACTIVE, DELETED)
// UI용 추가 속성
uploadProgress?: number; // 업로드 진행률 (0-100)
isUploading?: boolean; // 업로드 중 여부
hasError?: boolean; // 에러 발생 여부
errorMessage?: string; // 에러 메시지
}
// 위젯 컴포넌트
export interface WidgetComponent extends BaseComponent {
type: "widget";
@ -336,7 +393,8 @@ export type ComponentData =
| RowComponent
| ColumnComponent
| WidgetComponent
| DataTableComponent;
| DataTableComponent
| FileComponent;
// 레이아웃 데이터
export interface LayoutData {