Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into dataflowMng
This commit is contained in:
commit
6b6c62f3b7
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -243,6 +243,7 @@ model attach_file_info {
|
||||||
file_size Decimal? @db.Decimal
|
file_size Decimal? @db.Decimal
|
||||||
file_ext String? @default("NULL::character varying") @db.VarChar(32)
|
file_ext String? @default("NULL::character varying") @db.VarChar(32)
|
||||||
file_path String? @default("NULL::character varying") @db.VarChar(512)
|
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)
|
writer String? @default("NULL::character varying") @db.VarChar(32)
|
||||||
regdate DateTime? @db.Timestamp(6)
|
regdate DateTime? @db.Timestamp(6)
|
||||||
status String? @default("NULL::character varying") @db.VarChar(32)
|
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([doc_type, objid], map: "attach_file_info_doc_type_idx")
|
||||||
@@index([target_objid])
|
@@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 {
|
model authority_master {
|
||||||
|
|
|
||||||
|
|
@ -181,20 +181,38 @@ export class AuthController {
|
||||||
return;
|
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,
|
userId: dbUserInfo.userId,
|
||||||
userName: dbUserInfo.userName || "",
|
userName: dbUserInfo.userName || "",
|
||||||
deptName: dbUserInfo.deptName || "",
|
deptName: dbUserInfo.deptName || "",
|
||||||
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
companyCode: dbUserInfo.companyCode || "ILSHIN",
|
||||||
|
company_code: dbUserInfo.companyCode || "ILSHIN", // 프론트엔드 호환성
|
||||||
userType: dbUserInfo.userType || "USER",
|
userType: dbUserInfo.userType || "USER",
|
||||||
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
userTypeName: dbUserInfo.userTypeName || "일반사용자",
|
||||||
email: dbUserInfo.email || "",
|
email: dbUserInfo.email || "",
|
||||||
photo: dbUserInfo.photo,
|
photo: dbUserInfo.photo,
|
||||||
locale: dbUserInfo.locale || "KR", // locale 정보 추가
|
locale: dbUserInfo.locale || "KR", // locale 정보 추가
|
||||||
|
deptCode: dbUserInfo.deptCode, // 추가 필드
|
||||||
isAdmin:
|
isAdmin:
|
||||||
dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin",
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "사용자 정보 조회 성공",
|
message: "사용자 정보 조회 성공",
|
||||||
|
|
|
||||||
|
|
@ -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<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[];
|
||||||
|
|
||||||
|
// 파라미터 확인 및 로깅
|
||||||
|
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<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 getLinkedFiles = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
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<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개 파일
|
||||||
|
|
@ -1,497 +1,53 @@
|
||||||
import express from "express";
|
import { Router } from "express";
|
||||||
import multer from "multer";
|
import {
|
||||||
import path from "path";
|
uploadFiles,
|
||||||
import fs from "fs";
|
deleteFile,
|
||||||
|
getFileList,
|
||||||
|
downloadFile,
|
||||||
|
getLinkedFiles,
|
||||||
|
uploadMiddleware,
|
||||||
|
} from "../controllers/fileController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
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();
|
||||||
|
|
||||||
// 파일 저장 경로 설정
|
// 모든 파일 API는 인증 필요
|
||||||
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}`));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 업로드
|
* @route POST /api/files/upload
|
||||||
* POST /api/files/upload
|
* @desc 파일 업로드 (attach_file_info 테이블에 저장)
|
||||||
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post("/upload", uploadMiddleware, uploadFiles);
|
||||||
"/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 : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 다운로드
|
* @route GET /api/files
|
||||||
* GET /api/files/download/:fileId
|
* @desc 파일 목록 조회
|
||||||
|
* @query targetObjid, docType, companyCode
|
||||||
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get("/", getFileList);
|
||||||
"/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 : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 삭제
|
* @route GET /api/files/linked/:tableName/:recordId
|
||||||
* DELETE /api/files/:fileId
|
* @desc 테이블 연결된 파일 조회
|
||||||
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.delete(
|
router.get("/linked/:tableName/:recordId", getLinkedFiles);
|
||||||
"/: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 : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 미리보기
|
* @route DELETE /api/files/:objid
|
||||||
* GET /api/files/preview/:fileId
|
* @desc 파일 삭제 (논리적 삭제)
|
||||||
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.delete("/:objid", deleteFile);
|
||||||
"/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 : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 정보 조회
|
* @route GET /api/files/download/:objid
|
||||||
* GET /api/files/info/:fileId
|
* @desc 파일 다운로드
|
||||||
|
* @access Private
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get("/download/:objid", downloadFile);
|
||||||
"/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 : "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -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로 변환)
|
// PersonBean 형태로 변환 (null 값을 undefined로 변환)
|
||||||
const personBean: PersonBean = {
|
const personBean: PersonBean = {
|
||||||
userId: userInfo.user_id,
|
userId: userInfo.user_id,
|
||||||
|
|
@ -209,6 +222,12 @@ export class AuthService {
|
||||||
locale: userInfo.locale || "KR",
|
locale: userInfo.locale || "KR",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("📦 AuthService - 최종 PersonBean:", {
|
||||||
|
userId: personBean.userId,
|
||||||
|
companyCode: personBean.companyCode,
|
||||||
|
deptCode: personBean.deptCode,
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||||
return personBean;
|
return personBean;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 데이터 조회 (페이징 + 검색)
|
* 테이블 데이터 조회 (페이징 + 검색)
|
||||||
*/
|
*/
|
||||||
|
|
@ -565,6 +680,9 @@ export class TableManagementService {
|
||||||
|
|
||||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||||
|
|
||||||
|
// 🎯 파일 타입 컬럼 감지
|
||||||
|
const fileColumns = await this.getFileTypeColumns(tableName);
|
||||||
|
|
||||||
// WHERE 조건 구성
|
// WHERE 조건 구성
|
||||||
let whereConditions: string[] = [];
|
let whereConditions: string[] = [];
|
||||||
let searchValues: any[] = [];
|
let searchValues: any[] = [];
|
||||||
|
|
@ -621,13 +739,18 @@ export class TableManagementService {
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const data = await prisma.$queryRawUnsafe<any[]>(
|
let data = await prisma.$queryRawUnsafe<any[]>(
|
||||||
dataQuery,
|
dataQuery,
|
||||||
...searchValues,
|
...searchValues,
|
||||||
size,
|
size,
|
||||||
offset
|
offset
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🎯 파일 컬럼이 있으면 파일 정보 보강
|
||||||
|
if (fileColumns.length > 0) {
|
||||||
|
data = await this.enrichFileData(data, fileColumns, safeTableName);
|
||||||
|
}
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / size);
|
const totalPages = Math.ceil(total / size);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -35,24 +35,42 @@ import {
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
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 { cn } from "@/lib/utils";
|
||||||
import { downloadFile } from "@/lib/api/file";
|
import { downloadFile, getLinkedFiles } from "@/lib/api/file";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||||
|
|
||||||
// 파일 데이터 타입 정의
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
id: string;
|
// AttachedFileInfo 기본 속성들
|
||||||
name: string;
|
objid: string;
|
||||||
size: number;
|
savedFileName: string;
|
||||||
type: string;
|
realFileName: string;
|
||||||
extension: string;
|
fileSize: number;
|
||||||
uploadedAt: string;
|
fileExt: string;
|
||||||
lastModified: string;
|
filePath: string;
|
||||||
serverFilename?: 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 {
|
interface FileColumnData {
|
||||||
|
|
@ -94,6 +112,112 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
|
|
||||||
|
// 파일 관리 상태
|
||||||
|
const [fileStatusMap, setFileStatusMap] = useState<Record<string, { hasFiles: boolean; fileCount: number }>>({}); // 행별 파일 상태
|
||||||
|
const [showFileManagementModal, setShowFileManagementModal] = useState(false);
|
||||||
|
const [selectedRowForFiles, setSelectedRowForFiles] = useState<Record<string, any> | null>(null);
|
||||||
|
const [selectedColumnForFiles, setSelectedColumnForFiles] = useState<DataTableColumn | null>(null); // 선택된 컬럼 정보
|
||||||
|
const [linkedFiles, setLinkedFiles] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 파일 상태 확인 함수
|
||||||
|
const checkFileStatus = useCallback(
|
||||||
|
async (rowData: Record<string, any>) => {
|
||||||
|
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<string, any>) => {
|
||||||
|
const fileStatus = await checkFileStatus(rowData);
|
||||||
|
if (fileStatus) {
|
||||||
|
setSelectedRowForFiles(rowData);
|
||||||
|
setLinkedFiles(fileStatus.files);
|
||||||
|
setShowFileManagementModal(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[checkFileStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼별 파일 상태 확인
|
||||||
|
const checkColumnFileStatus = useCallback(
|
||||||
|
async (rowData: Record<string, any>, 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<string, any>, column: DataTableColumn) => {
|
||||||
|
// 컬럼별 파일 상태 확인
|
||||||
|
const fileStatus = await checkColumnFileStatus(rowData, column);
|
||||||
|
setSelectedRowForFiles(rowData);
|
||||||
|
setSelectedColumnForFiles(column); // 선택된 컬럼 정보 저장
|
||||||
|
setLinkedFiles(fileStatus?.files || []);
|
||||||
|
setShowFileManagementModal(true);
|
||||||
|
|
||||||
|
// TODO: 모달에 컬럼 정보 전달하여 해당 컬럼 전용 파일 업로드 가능하게 하기
|
||||||
|
},
|
||||||
|
[checkColumnFileStatus],
|
||||||
|
);
|
||||||
|
|
||||||
// 이미지 미리보기 핸들러들
|
// 이미지 미리보기 핸들러들
|
||||||
const handlePreviewImage = useCallback((fileInfo: FileInfo) => {
|
const handlePreviewImage = useCallback((fileInfo: FileInfo) => {
|
||||||
setPreviewImage(fileInfo);
|
setPreviewImage(fileInfo);
|
||||||
|
|
@ -200,25 +324,92 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
console.log("🔍 테이블 데이터 조회:", {
|
|
||||||
tableName: component.tableName,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
searchParams,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||||
page,
|
page,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
search: searchParams,
|
search: searchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 테이블 데이터 조회 결과:", result);
|
|
||||||
|
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setTotalPages(result.totalPages);
|
setTotalPages(result.totalPages);
|
||||||
setCurrentPage(result.page);
|
setCurrentPage(result.page);
|
||||||
|
|
||||||
|
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
||||||
|
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
|
||||||
|
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<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||||
|
|
||||||
|
// 가상 파일 컬럼 찾기
|
||||||
|
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<string, { hasFiles: boolean; fileCount: number }> = {
|
||||||
|
[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<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||||
|
|
||||||
|
results.forEach((result) => {
|
||||||
|
Object.assign(statusMap, result.statuses);
|
||||||
|
});
|
||||||
|
|
||||||
|
setFileStatusMap(statusMap);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 데이터 조회 실패:", error);
|
console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||||
setData([]);
|
setData([]);
|
||||||
|
|
@ -251,10 +442,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTableColumns = async () => {
|
const fetchTableColumns = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 테이블 컬럼 정보 로드 시작:", component.tableName);
|
|
||||||
const columns = await tableTypeApi.getColumns(component.tableName);
|
const columns = await tableTypeApi.getColumns(component.tableName);
|
||||||
setTableColumns(columns);
|
setTableColumns(columns);
|
||||||
console.log("✅ 테이블 컬럼 정보 로드 완료:", columns);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 컬럼 정보 로드 실패:", error);
|
console.error("테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -272,7 +461,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
// 검색 실행
|
// 검색 실행
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
console.log("🔍 검색 실행:", searchValues);
|
|
||||||
loadData(1, searchValues);
|
loadData(1, searchValues);
|
||||||
}, [searchValues, loadData]);
|
}, [searchValues, loadData]);
|
||||||
|
|
||||||
|
|
@ -512,8 +700,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
} else {
|
} else {
|
||||||
handleAddFormChange(columnName, fileNames);
|
handleAddFormChange(columnName, fileNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 파일 업로드 완료:", validFiles);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("파일 업로드 실패:", error);
|
console.error("파일 업로드 실패:", error);
|
||||||
alert("파일 업로드에 실패했습니다.");
|
alert("파일 업로드에 실패했습니다.");
|
||||||
|
|
@ -1280,23 +1466,15 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 파일 모달 열기
|
|
||||||
const openFileModal = (fileData: FileColumnData, column: DataTableColumn) => {
|
|
||||||
setCurrentFileData(fileData);
|
|
||||||
setCurrentFileColumn(column);
|
|
||||||
setShowFileModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 파일 다운로드
|
// 파일 다운로드
|
||||||
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
||||||
try {
|
try {
|
||||||
console.log("📥 파일 다운로드 시작:", fileInfo);
|
// savedFileName이 없는 경우 파일 경로에서 추출 시도
|
||||||
|
const serverFilename = fileInfo.savedFileName || (fileInfo.path ? fileInfo.path.split("/").pop() : null);
|
||||||
|
|
||||||
// serverFilename이 없는 경우 처리
|
if (!serverFilename) {
|
||||||
if (!fileInfo.serverFilename) {
|
|
||||||
// _file 속성이 있는 경우 로컬 파일로 다운로드
|
// _file 속성이 있는 경우 로컬 파일로 다운로드
|
||||||
if ((fileInfo as any)._file) {
|
if ((fileInfo as any)._file) {
|
||||||
console.log("📁 로컬 파일 다운로드 시도:", fileInfo.name);
|
|
||||||
try {
|
try {
|
||||||
const file = (fileInfo as any)._file;
|
const file = (fileInfo as any)._file;
|
||||||
|
|
||||||
|
|
@ -1307,12 +1485,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📁 유효한 파일 객체 확인됨:", {
|
|
||||||
name: file.name || fileInfo.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|
@ -1337,8 +1509,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
toast.loading(`${fileInfo.name} 다운로드 중...`);
|
toast.loading(`${fileInfo.name} 다운로드 중...`);
|
||||||
|
|
||||||
await downloadFile({
|
await downloadFile({
|
||||||
fileId: fileInfo.id,
|
fileId: fileInfo.objid || fileInfo.id,
|
||||||
serverFilename: fileInfo.serverFilename,
|
serverFilename: serverFilename,
|
||||||
originalName: fileInfo.name,
|
originalName: fileInfo.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1350,56 +1522,53 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 셀 값 포맷팅
|
// 셀 값 포맷팅
|
||||||
const formatCellValue = (value: any, column: DataTableColumn): React.ReactNode => {
|
const formatCellValue = (value: any, column: DataTableColumn, rowData?: Record<string, any>): React.ReactNode => {
|
||||||
if (value === null || value === undefined) return "";
|
// 가상 파일 컬럼의 경우 value가 없어도 파일 아이콘을 표시해야 함
|
||||||
|
if (!column.isVirtualFileColumn && (value === null || value === undefined)) return "";
|
||||||
|
|
||||||
// 디버깅을 위한 로그 추가
|
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
||||||
if (column.columnName === "file_path") {
|
const isFileColumn =
|
||||||
console.log("📊 formatCellValue (file_path 컬럼):", {
|
column.widgetType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
||||||
columnName: column.columnName,
|
|
||||||
widgetType: column.widgetType,
|
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
||||||
value: value,
|
if (isFileColumn && rowData) {
|
||||||
valueType: typeof value,
|
// 현재 행의 기본키 값 가져오기
|
||||||
fullColumn: column,
|
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 (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 hover:bg-blue-50"
|
||||||
|
onClick={() => handleColumnFileClick(rowData, column)}
|
||||||
|
title={hasFiles ? `${fileCount}개 파일 보기` : "파일 업로드"}
|
||||||
|
>
|
||||||
|
{hasFiles ? (
|
||||||
|
<div className="relative">
|
||||||
|
<FolderOpen className="h-4 w-4 text-blue-600" />
|
||||||
|
{fileCount > 0 && (
|
||||||
|
<div className="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
|
||||||
|
{fileCount > 9 ? "9+" : fileCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Folder className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// file_path 컬럼은 강제로 파일 타입으로 처리 (임시 해결책)
|
|
||||||
const isFileColumn = column.widgetType === "file" || column.columnName === "file_path";
|
|
||||||
|
|
||||||
switch (column.widgetType) {
|
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 (
|
|
||||||
<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>;
|
|
||||||
|
|
||||||
case "date":
|
case "date":
|
||||||
if (value) {
|
if (value) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1557,13 +1726,26 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
{column.label}
|
{column.label}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
|
{/* 기본 파일 컬럼은 가상 파일 컬럼이 있으면 완전히 숨김 */}
|
||||||
|
{!visibleColumns.some((col) => col.widgetType === "file") && (
|
||||||
|
<TableHead className="w-16 px-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Folder className="h-4 w-4" />
|
||||||
|
<span className="text-xs">파일</span>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
colSpan={
|
||||||
|
visibleColumns.length +
|
||||||
|
(component.enableDelete ? 1 : 0) +
|
||||||
|
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
||||||
|
}
|
||||||
className="h-32 text-center"
|
className="h-32 text-center"
|
||||||
>
|
>
|
||||||
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
||||||
|
|
@ -1586,15 +1768,54 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
)}
|
)}
|
||||||
{visibleColumns.map((column: DataTableColumn) => (
|
{visibleColumns.map((column: DataTableColumn) => (
|
||||||
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
||||||
{formatCellValue(row[column.columnName], column)}
|
{formatCellValue(row[column.columnName], column, row)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
{/* 기본 파일 셀은 가상 파일 컬럼이 있으면 완전히 숨김 */}
|
||||||
|
{!visibleColumns.some((col) => col.widgetType === "file") && (
|
||||||
|
<TableCell className="w-16 px-4 text-center">
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 hover:bg-blue-50"
|
||||||
|
onClick={() => handleFileIconClick(row)}
|
||||||
|
title={hasFiles ? `${fileCount}개 파일 보기` : "파일 업로드"}
|
||||||
|
>
|
||||||
|
{hasFiles ? (
|
||||||
|
<div className="relative">
|
||||||
|
<FolderOpen className="h-4 w-4 text-blue-600" />
|
||||||
|
{fileCount > 0 && (
|
||||||
|
<div className="absolute -top-1 -right-1 flex h-3 w-3 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
|
||||||
|
{fileCount > 9 ? "9+" : fileCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Folder className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
colSpan={
|
||||||
|
visibleColumns.length +
|
||||||
|
(component.enableDelete ? 1 : 0) +
|
||||||
|
(!visibleColumns.some((col) => col.widgetType === "file") ? 1 : 0)
|
||||||
|
}
|
||||||
className="h-32 text-center"
|
className="h-32 text-center"
|
||||||
>
|
>
|
||||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||||
|
|
@ -1840,14 +2061,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<span>타입: {fileInfo.type || "알 수 없음"}</span>
|
<span>타입: {fileInfo.type || "알 수 없음"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>확장자: {fileInfo.extension || "N/A"}</span>
|
{fileInfo.regdate && (
|
||||||
<span>업로드: {new Date(fileInfo.uploadedAt).toLocaleString("ko-KR")}</span>
|
<span>등록일: {new Date(fileInfo.regdate).toLocaleString("ko-KR")}</span>
|
||||||
|
)}
|
||||||
|
{fileInfo.writer && <span>등록자: {fileInfo.writer}</span>}
|
||||||
</div>
|
</div>
|
||||||
{fileInfo.lastModified && (
|
|
||||||
<div>
|
|
||||||
<span>수정: {new Date(fileInfo.lastModified).toLocaleString("ko-KR")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1896,11 +2114,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
{" "}
|
{" "}
|
||||||
{(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB
|
{(currentFileData.totalSize / 1024 / 1024).toFixed(2)} MB
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="font-medium">최종 수정:</span>
|
|
||||||
{" "}
|
|
||||||
{new Date(currentFileData.lastModified).toLocaleString("ko-KR")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1983,7 +2196,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<img
|
<img
|
||||||
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.serverFilename}`}
|
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.savedFileName}`}
|
||||||
alt={previewImage.name}
|
alt={previewImage.name}
|
||||||
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -2006,6 +2219,205 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 파일 관리 모달 */}
|
||||||
|
<Dialog open={showFileManagementModal} onOpenChange={setShowFileManagementModal}>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Folder className="h-5 w-5" />
|
||||||
|
파일 관리
|
||||||
|
{selectedRowForFiles && (
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
{Object.keys(selectedRowForFiles)[0]}: {selectedRowForFiles[Object.keys(selectedRowForFiles)[0]]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{linkedFiles.length > 0
|
||||||
|
? `${linkedFiles.length}개의 파일이 연결되어 있습니다.`
|
||||||
|
: "연결된 파일이 없습니다. 새 파일을 업로드하세요."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기존 파일 목록 */}
|
||||||
|
{linkedFiles.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">연결된 파일</h4>
|
||||||
|
{linkedFiles.map((file: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<File className="h-5 w-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{file.realFileName}</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{(Number(file.fileSize) / 1024 / 1024).toFixed(2)} MB • {file.docTypeName}
|
||||||
|
{file.regdate && <span> • {new Date(file.regdate).toLocaleString("ko-KR")}</span>}
|
||||||
|
{file.writer && <span> • {file.writer}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{file.fileExt && ["jpg", "jpeg", "png", "gif"].includes(file.fileExt.toLowerCase()) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// 이미지 미리보기 (기존 로직 재사용)
|
||||||
|
const fileInfo: FileInfo = {
|
||||||
|
id: file.objid,
|
||||||
|
name: file.realFileName,
|
||||||
|
size: Number(file.fileSize),
|
||||||
|
type: `image/${file.fileExt}`,
|
||||||
|
path: file.filePath,
|
||||||
|
objid: file.objid,
|
||||||
|
extension: file.fileExt,
|
||||||
|
uploadedAt: file.regdate || new Date().toISOString(),
|
||||||
|
lastModified: file.regdate || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
handlePreviewImage(fileInfo);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
// 파일 다운로드 (기존 로직 재사용)
|
||||||
|
const fileInfo: FileInfo = {
|
||||||
|
id: file.objid,
|
||||||
|
name: file.realFileName,
|
||||||
|
size: Number(file.fileSize),
|
||||||
|
type: `application/${file.fileExt}`,
|
||||||
|
path: file.filePath,
|
||||||
|
objid: file.objid,
|
||||||
|
savedFileName: file.savedFileName,
|
||||||
|
};
|
||||||
|
handleDownloadFile(fileInfo);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 파일 업로드 섹션 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">{selectedColumnForFiles?.label || "파일"} 업로드</h4>
|
||||||
|
{selectedColumnForFiles?.isVirtualFileColumn && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{selectedColumnForFiles.fileColumnConfig?.docTypeName || "문서"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRowForFiles && selectedColumnForFiles && component.tableName && (
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<FileUpload
|
||||||
|
component={{
|
||||||
|
id: `modal-file-upload-${selectedColumnForFiles.id}`,
|
||||||
|
type: "file",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 400, height: 300 },
|
||||||
|
uploadedFiles: [], // 빈 배열로 초기화
|
||||||
|
fileConfig: {
|
||||||
|
maxSize: selectedColumnForFiles.fileColumnConfig?.maxFiles || 10,
|
||||||
|
maxFiles: selectedColumnForFiles.fileColumnConfig?.maxFiles || 5,
|
||||||
|
multiple: true,
|
||||||
|
showPreview: true,
|
||||||
|
showProgress: true,
|
||||||
|
autoUpload: true, // 자동 업로드 활성화
|
||||||
|
chunkedUpload: false, // 기본 업로드 방식
|
||||||
|
dragDropText: `${selectedColumnForFiles.label} 파일을 드래그하여 업로드하거나 클릭하세요`,
|
||||||
|
uploadButtonText: "파일 업로드", // 업로드 버튼 텍스트
|
||||||
|
accept: selectedColumnForFiles.fileColumnConfig?.accept || ["*/*"],
|
||||||
|
// 문서 분류 설정
|
||||||
|
docType: selectedColumnForFiles.fileColumnConfig?.docType || "DOCUMENT",
|
||||||
|
docTypeName: selectedColumnForFiles.fileColumnConfig?.docTypeName || "일반 문서",
|
||||||
|
// 자동 연결 설정
|
||||||
|
autoLink: true,
|
||||||
|
linkedTable: component.tableName,
|
||||||
|
linkedField: Object.keys(selectedRowForFiles)[0], // 기본키 필드
|
||||||
|
recordId: selectedRowForFiles[Object.keys(selectedRowForFiles)[0]], // 기본키 값
|
||||||
|
// 가상 파일 컬럼별 구분을 위한 추가 정보
|
||||||
|
columnName: selectedColumnForFiles.columnName,
|
||||||
|
isVirtualFileColumn: selectedColumnForFiles.isVirtualFileColumn,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onUpdateComponent={() => {
|
||||||
|
// 모달에서는 컴포넌트 업데이트가 필요 없으므로 빈 함수 제공
|
||||||
|
}}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowFileManagementModal(false)}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
DataTableComponent,
|
DataTableComponent,
|
||||||
|
FileComponent,
|
||||||
TextTypeConfig,
|
TextTypeConfig,
|
||||||
NumberTypeConfig,
|
NumberTypeConfig,
|
||||||
DateTypeConfig,
|
DateTypeConfig,
|
||||||
|
|
@ -32,6 +33,7 @@ import {
|
||||||
ButtonTypeConfig,
|
ButtonTypeConfig,
|
||||||
} from "@/types/screen";
|
} from "@/types/screen";
|
||||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||||
|
import { FileUpload } from "./widgets/FileUpload";
|
||||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
|
@ -56,7 +58,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
screenInfo,
|
screenInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { userName } = useAuth(); // 현재 로그인한 사용자명 가져오기
|
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
||||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
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") {
|
if (component.type === "group") {
|
||||||
const children = allComponents.filter((comp) => comp.parentId === component.id);
|
const children = allComponents.filter((comp) => comp.parentId === component.id);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
WebType,
|
WebType,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
|
FileComponent,
|
||||||
DateTypeConfig,
|
DateTypeConfig,
|
||||||
NumberTypeConfig,
|
NumberTypeConfig,
|
||||||
SelectTypeConfig,
|
SelectTypeConfig,
|
||||||
|
|
@ -24,6 +25,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { FileUpload } from "./widgets/FileUpload";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
// import { Checkbox } from "@/components/ui/checkbox";
|
// import { Checkbox } from "@/components/ui/checkbox";
|
||||||
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
|
|
@ -850,6 +853,7 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
||||||
children,
|
children,
|
||||||
onGroupToggle,
|
onGroupToggle,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
const { type, label, tableName, size, style } = component;
|
const { type, label, tableName, size, style } = component;
|
||||||
|
|
||||||
// 위젯 컴포넌트인 경우에만 columnName과 widgetType 접근
|
// 위젯 컴포넌트인 경우에만 columnName과 widgetType 접근
|
||||||
|
|
@ -1364,6 +1368,22 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
||||||
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
|
<div className="pointer-events-none flex-1">{renderWidget(component)}</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -991,6 +991,57 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
...templateComp.style,
|
...templateComp.style,
|
||||||
},
|
},
|
||||||
} as ComponentData;
|
} 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 {
|
} else {
|
||||||
// 위젯 컴포넌트
|
// 위젯 컴포넌트
|
||||||
const widgetType = templateComp.widgetType || "text";
|
const widgetType = templateComp.widgetType || "text";
|
||||||
|
|
@ -2959,6 +3010,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onUpdateProperty={(componentId: string, path: string, value: any) => {
|
onUpdateProperty={(componentId: string, path: string, value: any) => {
|
||||||
updateComponentProperty(componentId, path, value);
|
updateComponentProperty(componentId, path, value);
|
||||||
}}
|
}}
|
||||||
|
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||||
|
currentTableName={selectedScreen?.tableName}
|
||||||
/>
|
/>
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1061,6 +1061,71 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
[selectedTable, component.columns, component.filters, onUpdateComponent],
|
[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<DataTableComponent> = {
|
||||||
|
columns: [...component.columns, newColumn],
|
||||||
|
};
|
||||||
|
|
||||||
|
onUpdateComponent(updates);
|
||||||
|
|
||||||
|
// 컬럼 추가 후 컬럼 탭으로 자동 이동
|
||||||
|
setActiveTab("columns");
|
||||||
|
|
||||||
|
console.log("✅ 가상 파일 컬럼 추가 완료");
|
||||||
|
}, [component.columns, onUpdateComponent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-h-[80vh] p-4">
|
<div className="max-h-[80vh] p-4">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
|
@ -1459,6 +1524,14 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
<h3 className="text-sm font-medium">테이블 컬럼 설정</h3>
|
<h3 className="text-sm font-medium">테이블 컬럼 설정</h3>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge variant="secondary">{component.columns.length}개</Badge>
|
<Badge variant="secondary">{component.columns.length}개</Badge>
|
||||||
|
|
||||||
|
{/* 파일 컬럼 추가 버튼 */}
|
||||||
|
<Button size="sm" variant="outline" onClick={addVirtualFileColumn} className="h-8 text-xs">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="ml-1">파일 컬럼</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 기존 DB 컬럼 추가 */}
|
||||||
{selectedTable &&
|
{selectedTable &&
|
||||||
(() => {
|
(() => {
|
||||||
const availableColumns = selectedTable.columns.filter(
|
const availableColumns = selectedTable.columns.filter(
|
||||||
|
|
@ -1468,7 +1541,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
return availableColumns.length > 0 ? (
|
return availableColumns.length > 0 ? (
|
||||||
<Select onValueChange={(value) => addColumn(value)}>
|
<Select onValueChange={(value) => addColumn(value)}>
|
||||||
<SelectTrigger className="h-8 w-32 text-xs">
|
<SelectTrigger className="h-8 w-32 text-xs">
|
||||||
<SelectValue placeholder="컬럼 추가" />
|
<SelectValue placeholder="DB 컬럼" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableColumns.map((col) => (
|
{availableColumns.map((col) => (
|
||||||
|
|
@ -1481,7 +1554,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" disabled>
|
<Button size="sm" disabled>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="ml-1 text-xs">모든 컬럼 추가됨</span>
|
<span className="ml-1 text-xs">모든 DB 컬럼 추가됨</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import {
|
import {
|
||||||
ComponentData,
|
ComponentData,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
|
FileComponent,
|
||||||
WebTypeConfig,
|
WebTypeConfig,
|
||||||
DateTypeConfig,
|
DateTypeConfig,
|
||||||
NumberTypeConfig,
|
NumberTypeConfig,
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
CodeTypeConfig,
|
CodeTypeConfig,
|
||||||
EntityTypeConfig,
|
EntityTypeConfig,
|
||||||
ButtonTypeConfig,
|
ButtonTypeConfig,
|
||||||
|
TableInfo,
|
||||||
} from "@/types/screen";
|
} from "@/types/screen";
|
||||||
import { DateTypeConfigPanel } from "./webtype-configs/DateTypeConfigPanel";
|
import { DateTypeConfigPanel } from "./webtype-configs/DateTypeConfigPanel";
|
||||||
import { NumberTypeConfigPanel } from "./webtype-configs/NumberTypeConfigPanel";
|
import { NumberTypeConfigPanel } from "./webtype-configs/NumberTypeConfigPanel";
|
||||||
|
|
@ -30,13 +32,21 @@ import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
|
||||||
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
|
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
|
||||||
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
|
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
|
||||||
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
import { ButtonConfigPanel } from "./ButtonConfigPanel";
|
||||||
|
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||||
|
|
||||||
interface DetailSettingsPanelProps {
|
interface DetailSettingsPanelProps {
|
||||||
selectedComponent?: ComponentData;
|
selectedComponent?: ComponentData;
|
||||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||||
|
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
||||||
|
currentTableName?: string; // 현재 화면의 테이블명
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ selectedComponent, onUpdateProperty }) => {
|
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
selectedComponent,
|
||||||
|
onUpdateProperty,
|
||||||
|
currentTable,
|
||||||
|
currentTableName,
|
||||||
|
}) => {
|
||||||
// 입력 가능한 웹타입들 정의
|
// 입력 가능한 웹타입들 정의
|
||||||
const inputableWebTypes = [
|
const inputableWebTypes = [
|
||||||
"text",
|
"text",
|
||||||
|
|
@ -214,13 +224,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ select
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedComponent.type !== "widget") {
|
if (selectedComponent.type !== "widget" && selectedComponent.type !== "file") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
<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" />
|
<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">
|
<p className="text-sm text-gray-500">
|
||||||
상세 설정은 위젯 컴포넌트에서만 사용할 수 있습니다.
|
상세 설정은 위젯 컴포넌트와 파일 컴포넌트에서만 사용할 수 있습니다.
|
||||||
<br />
|
<br />
|
||||||
현재 선택된 컴포넌트: {selectedComponent.type}
|
현재 선택된 컴포넌트: {selectedComponent.type}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -228,6 +238,38 @@ 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}
|
||||||
|
currentTable={currentTable}
|
||||||
|
currentTableName={currentTableName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const widget = selectedComponent as WidgetComponent;
|
const widget = selectedComponent as WidgetComponent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,459 @@
|
||||||
|
"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, TableInfo } 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;
|
||||||
|
currentTable?: TableInfo; // 현재 화면의 테이블 정보
|
||||||
|
currentTableName?: string; // 현재 화면의 테이블명
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> = ({
|
||||||
|
component,
|
||||||
|
onUpdateProperty,
|
||||||
|
currentTable,
|
||||||
|
currentTableName,
|
||||||
|
}) => {
|
||||||
|
// 로컬 상태
|
||||||
|
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: "", // 새 파일 타입 추가용
|
||||||
|
linkedTable: component.fileConfig.linkedTable || "", // 연결 테이블
|
||||||
|
linkedField: component.fileConfig.linkedField || "", // 연결 필드
|
||||||
|
});
|
||||||
|
|
||||||
|
const [localValues, setLocalValues] = useState({
|
||||||
|
multiple: component.fileConfig.multiple ?? true,
|
||||||
|
showPreview: component.fileConfig.showPreview ?? true,
|
||||||
|
showProgress: component.fileConfig.showProgress ?? true,
|
||||||
|
autoLink: component.fileConfig.autoLink ?? false, // 자동 연결
|
||||||
|
});
|
||||||
|
|
||||||
|
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: "",
|
||||||
|
linkedTable: component.fileConfig.linkedTable || "",
|
||||||
|
linkedField: component.fileConfig.linkedField || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalValues({
|
||||||
|
multiple: component.fileConfig.multiple ?? true,
|
||||||
|
showPreview: component.fileConfig.showPreview ?? true,
|
||||||
|
showProgress: component.fileConfig.showProgress ?? true,
|
||||||
|
autoLink: component.fileConfig.autoLink ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 className="mt-6 rounded-lg border bg-blue-50 p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-blue-900">📎 테이블 연결 설정</h4>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="autoLink"
|
||||||
|
checked={localValues.autoLink}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setLocalValues((prev) => ({ ...prev, autoLink: checked as boolean }));
|
||||||
|
onUpdateProperty(component.id, "fileConfig.autoLink", checked);
|
||||||
|
|
||||||
|
// 자동 연결이 활성화되면 현재 화면의 테이블 정보를 자동 설정
|
||||||
|
if (checked && currentTableName && currentTable) {
|
||||||
|
// 기본키 추정 로직 (일반적인 패턴들)
|
||||||
|
const primaryKeyGuesses = [
|
||||||
|
`${currentTableName}_id`, // table_name + "_id"
|
||||||
|
`${currentTableName.replace(/_/g, "")}_id`, // undercore 제거 + "_id"
|
||||||
|
currentTableName.endsWith("_info") || currentTableName.endsWith("_mng")
|
||||||
|
? currentTableName.replace(/_(info|mng)$/, "_code") // _info, _mng -> _code
|
||||||
|
: `${currentTableName}_code`, // table_name + "_code"
|
||||||
|
"id", // 단순 "id"
|
||||||
|
"objid", // "objid"
|
||||||
|
];
|
||||||
|
|
||||||
|
// 실제 테이블 컬럼에서 기본키로 추정되는 컬럼 찾기
|
||||||
|
let detectedPrimaryKey = "";
|
||||||
|
for (const guess of primaryKeyGuesses) {
|
||||||
|
const foundColumn = currentTable.columns.find(
|
||||||
|
(col) => col.columnName.toLowerCase() === guess.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (foundColumn) {
|
||||||
|
detectedPrimaryKey = foundColumn.columnName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 찾지 못한 경우 첫 번째 컬럼을 기본키로 사용
|
||||||
|
if (!detectedPrimaryKey && currentTable.columns.length > 0) {
|
||||||
|
detectedPrimaryKey = currentTable.columns[0].columnName;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔗 자동 테이블 연결 설정:", {
|
||||||
|
tableName: currentTableName,
|
||||||
|
detectedPrimaryKey,
|
||||||
|
availableColumns: currentTable.columns.map((c) => c.columnName),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동으로 테이블명과 기본키 설정
|
||||||
|
setLocalInputs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
linkedTable: currentTableName,
|
||||||
|
linkedField: detectedPrimaryKey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
onUpdateProperty(component.id, "fileConfig.linkedTable", currentTableName);
|
||||||
|
onUpdateProperty(component.id, "fileConfig.linkedField", detectedPrimaryKey);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="autoLink">다른 테이블과 자동 연결</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localValues.autoLink && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="linkedTable">연결할 테이블명</Label>
|
||||||
|
<Input
|
||||||
|
id="linkedTable"
|
||||||
|
value={localInputs.linkedTable}
|
||||||
|
readOnly
|
||||||
|
className="bg-gray-50 text-gray-700"
|
||||||
|
placeholder="자동으로 설정됩니다"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-green-600">✅ 현재 화면의 테이블이 자동으로 설정됩니다</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="linkedField">연결할 필드명 (기본키)</Label>
|
||||||
|
<Input
|
||||||
|
id="linkedField"
|
||||||
|
value={localInputs.linkedField}
|
||||||
|
readOnly
|
||||||
|
className="bg-gray-50 text-gray-700"
|
||||||
|
placeholder="자동으로 감지됩니다"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-green-600">✅ 테이블의 기본키가 자동으로 감지됩니다</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded bg-blue-100 p-2 text-xs text-blue-600">
|
||||||
|
💡 이 설정을 활성화하면 파일이 현재 레코드와 자동으로 연결됩니다.
|
||||||
|
<br />
|
||||||
|
{currentTableName && localInputs.linkedField ? (
|
||||||
|
<>
|
||||||
|
예: {currentTableName} 테이블의 {localInputs.linkedField}가 "값123"인 레코드에 파일을 업로드하면
|
||||||
|
<br />
|
||||||
|
target_objid가 "{currentTableName}:값123"로 설정됩니다.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>테이블과 기본키 정보가 자동으로 설정되면 연결 예시가 표시됩니다.</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Settings,
|
Settings,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// 템플릿 컴포넌트 타입 정의
|
// 템플릿 컴포넌트 타입 정의
|
||||||
|
|
@ -29,11 +30,11 @@ export interface TemplateComponent {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: "table" | "button" | "form" | "layout" | "chart" | "status";
|
category: "table" | "button" | "form" | "layout" | "chart" | "status" | "file";
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
defaultSize: { width: number; height: number };
|
defaultSize: { width: number; height: number };
|
||||||
components: Array<{
|
components: Array<{
|
||||||
type: "widget" | "container";
|
type: "widget" | "container" | "datatable" | "file";
|
||||||
widgetType?: string;
|
widgetType?: string;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder?: 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 {
|
interface TemplatesPanelProps {
|
||||||
|
|
@ -111,6 +136,8 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
|
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
|
||||||
{ id: "table", name: "테이블", icon: <Table 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) => {
|
const filteredTemplates = templateComponents.filter((template) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,692 @@
|
||||||
|
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;
|
||||||
|
onFileUpload?: (files: AttachedFileInfo[]) => void; // 파일 업로드 완료 콜백
|
||||||
|
userInfo?: any; // 사용자 정보 (선택적)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 독립적인 File 컴포넌트
|
||||||
|
* attach_file_info 테이블 기반 파일 관리
|
||||||
|
*/
|
||||||
|
export function FileUpload({ component, onUpdateComponent, onFileUpload, userInfo }: FileUploadProps) {
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
|
||||||
|
const [localUploadedFiles, setLocalUploadedFiles] = useState<AttachedFileInfo[]>(component.uploadedFiles || []);
|
||||||
|
const 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();
|
||||||
|
|
||||||
|
console.log("🔍 파일 타입 검증:", {
|
||||||
|
fileName: file.name,
|
||||||
|
fileType: file.type,
|
||||||
|
acceptRules: fileConfig.accept,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = fileConfig.accept.some((accept) => {
|
||||||
|
// 모든 파일 허용 (와일드카드)
|
||||||
|
if (accept === "*/*" || accept === "*") {
|
||||||
|
console.log("✅ 와일드카드 매칭:", accept);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 확장자 기반 검증 (.jpg, .png 등)
|
||||||
|
if (accept.startsWith(".")) {
|
||||||
|
const matches = fileName.endsWith(accept.toLowerCase());
|
||||||
|
console.log(`${matches ? "✅" : "❌"} 확장자 검증:`, accept, "→", matches);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME 타입 기반 검증 (image/*, text/* 등)
|
||||||
|
if (accept.includes("/*")) {
|
||||||
|
const type = accept.split("/")[0];
|
||||||
|
const matches = file.type.startsWith(type);
|
||||||
|
console.log(`${matches ? "✅" : "❌"} MIME 타입 검증:`, accept, "→", matches);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정확한 MIME 타입 매칭 (image/jpeg, application/pdf 등)
|
||||||
|
const matches = file.type === accept;
|
||||||
|
console.log(`${matches ? "✅" : "❌"} 정확한 MIME 매칭:`, accept, "→", matches);
|
||||||
|
return matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🎯 최종 검증 결과:`, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 선택 핸들러
|
||||||
|
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("🚀 자동 업로드 시작:", {
|
||||||
|
autoUpload: fileConfig.autoUpload,
|
||||||
|
filesCount: validFiles.length,
|
||||||
|
fileNames: validFiles.map((f) => f.name),
|
||||||
|
});
|
||||||
|
// 자동 업로드 실행
|
||||||
|
validFiles.forEach(uploadFile);
|
||||||
|
} else {
|
||||||
|
console.log("⏸️ 자동 업로드 비활성화:", {
|
||||||
|
autoUpload: fileConfig.autoUpload,
|
||||||
|
filesCount: validFiles.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프론트엔드 파일 타입 설정을 백엔드로 전송
|
||||||
|
if (fileConfig.accept && fileConfig.accept.length > 0) {
|
||||||
|
const acceptString = fileConfig.accept.join(",");
|
||||||
|
formData.append("accept", acceptString);
|
||||||
|
console.log("✅ 허용 파일 타입 추가:", acceptString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 연결 정보 추가
|
||||||
|
if (fileConfig.autoLink) {
|
||||||
|
formData.append("autoLink", "true");
|
||||||
|
console.log("✅ 자동 연결 활성화: true");
|
||||||
|
|
||||||
|
if (fileConfig.linkedTable) {
|
||||||
|
formData.append("linkedTable", fileConfig.linkedTable);
|
||||||
|
console.log("✅ 연결 테이블 추가:", fileConfig.linkedTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileConfig.linkedField) {
|
||||||
|
formData.append("linkedField", fileConfig.linkedField);
|
||||||
|
console.log("✅ 연결 필드 추가:", fileConfig.linkedField);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileConfig.recordId) {
|
||||||
|
formData.append("recordId", fileConfig.recordId);
|
||||||
|
console.log("✅ 레코드 ID 추가:", fileConfig.recordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 가상 파일 컬럼 정보 추가
|
||||||
|
if (fileConfig.isVirtualFileColumn) {
|
||||||
|
formData.append("isVirtualFileColumn", "true");
|
||||||
|
console.log("✅ 가상 파일 컬럼 활성화: true");
|
||||||
|
|
||||||
|
if (fileConfig.columnName) {
|
||||||
|
formData.append("columnName", fileConfig.columnName);
|
||||||
|
console.log("✅ 컬럼명 추가:", fileConfig.columnName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 컴포넌트 업데이트 (옵셔널)
|
||||||
|
if (onUpdateComponent) {
|
||||||
|
onUpdateComponent({
|
||||||
|
uploadedFiles: updatedFiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 업로드 완료 콜백 호출 (모달에서 사용)
|
||||||
|
if (onFileUpload) {
|
||||||
|
onFileUpload(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,8 @@ interface UserInfo {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
sabun?: string;
|
sabun?: string;
|
||||||
photo?: string | null;
|
photo?: string | null;
|
||||||
company_code?: string; // 회사 코드 추가
|
companyCode?: string; // 백엔드와 일치하도록 수정
|
||||||
|
company_code?: string; // 하위 호환성을 위해 유지
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 상태 타입 정의
|
// 인증 상태 타입 정의
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,12 @@ apiClient.interceptors.request.use(
|
||||||
console.warn("⚠️ 토큰이 없습니다.");
|
console.warn("⚠️ 토큰이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormData 요청 시 Content-Type 자동 처리
|
||||||
|
if (config.data instanceof FormData) {
|
||||||
|
console.log("📎 FormData 감지 - Content-Type 헤더 제거");
|
||||||
|
delete config.headers["Content-Type"];
|
||||||
|
}
|
||||||
|
|
||||||
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
|
// 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만)
|
||||||
if (config.method?.toUpperCase() === "GET") {
|
if (config.method?.toUpperCase() === "GET") {
|
||||||
// 우선순위: 전역 변수 > localStorage > 기본값
|
// 우선순위: 전역 변수 > localStorage > 기본값
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export const uploadFiles = async (files: FileList): Promise<FileUploadResponse>
|
||||||
|
|
||||||
const response = await apiClient.post("/files/upload", formData, {
|
const response = await apiClient.post("/files/upload", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -144,3 +144,29 @@ export const uploadFilesAndCreateData = async (files: FileList) => {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 연결된 파일 조회
|
||||||
|
*/
|
||||||
|
export const getLinkedFiles = async (
|
||||||
|
tableName: string,
|
||||||
|
recordId: string,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
files: any[];
|
||||||
|
totalCount: number;
|
||||||
|
targetObjid: string;
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
console.log("📎 연결된 파일 조회:", { tableName, recordId });
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/files/linked/${tableName}/${recordId}`);
|
||||||
|
|
||||||
|
console.log("✅ 연결된 파일 조회 성공:", response.data);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연결된 파일 조회 오류:", error);
|
||||||
|
throw new Error("연결된 파일 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@xyflow/react": "^12.8.4",
|
"@xyflow/react": "^12.8.4",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tanstack/react-query-devtools": "^5.85.6",
|
"@tanstack/react-query-devtools": "^5.86.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
@ -2463,9 +2463,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.85.6",
|
"version": "5.86.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.86.0.tgz",
|
||||||
"integrity": "sha512-hCj0TktzdCv2bCepIdfwqVwUVWb+GSHm1Jnn8w+40lfhQ3m7lCO7ADRUJy+2unxQ/nzjh2ipC6ye69NDW3l73g==",
|
"integrity": "sha512-Y6ibQm6BXbw6w1p3a5LrPn8Ae64M0dx7hGmnhrm9P+XAkCCKXOwZN0J5Z1wK/0RdNHtR9o+sWHDXd4veNI60tQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2473,9 +2473,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-devtools": {
|
"node_modules/@tanstack/query-devtools": {
|
||||||
"version": "5.84.0",
|
"version": "5.86.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.84.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.86.0.tgz",
|
||||||
"integrity": "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==",
|
"integrity": "sha512-/JDw9BP80eambEK/EsDMGAcsL2VFT+8F5KCOwierjPU7QP8Wt1GT32yJpn3qOinBM8/zS3Jy36+F0GiyJp411A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|
@ -2484,12 +2484,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.85.6",
|
"version": "5.86.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.86.0.tgz",
|
||||||
"integrity": "sha512-VUAag4ERjh+qlmg0wNivQIVCZUrYndqYu3/wPCVZd4r0E+1IqotbeyGTc+ICroL/PqbpSaGZg02zSWYfcvxbdA==",
|
"integrity": "sha512-jgS/v0oSJkGHucv9zxOS8rL7mjATh1XO3K4eqAV4WMpAly8okcBrGi1YxRZN5S4B59F54x9JFjWrK5vMAvJYqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.85.6"
|
"@tanstack/query-core": "5.86.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -2500,20 +2500,20 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query-devtools": {
|
"node_modules/@tanstack/react-query-devtools": {
|
||||||
"version": "5.85.6",
|
"version": "5.86.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.85.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.86.0.tgz",
|
||||||
"integrity": "sha512-A6rE39FypFV7eonefk4fxC/vuV/7YJMAcQT94CFAvCpiw65QZX8MOuUpdLBeG1cXajy4Pj8T8sEWHigccntJqg==",
|
"integrity": "sha512-+50IcXI+54qHx3IDccbTala4tkToKxa0WKqP4XWlTnP1mQNfHO3dJj8wwnzpG50os69kpSbnU8C98Q/i8b6lyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-devtools": "5.84.0"
|
"@tanstack/query-devtools": "5.86.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tanstack/react-query": "^5.85.6",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@xyflow/react": "^12.8.4",
|
"@xyflow/react": "^12.8.4",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tanstack/react-query-devtools": "^5.85.6",
|
"@tanstack/react-query-devtools": "^5.86.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
export type WebType =
|
||||||
|
|
@ -208,6 +208,73 @@ export interface ColumnComponent extends BaseComponent {
|
||||||
children?: string[]; // 자식 컴포넌트 ID 목록
|
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 (계층 구조용)
|
||||||
|
|
||||||
|
// 테이블 연결 설정 (새로 추가)
|
||||||
|
linkedTable?: string; // 연결할 테이블명 (예: company_mng, user_info)
|
||||||
|
linkedField?: string; // 연결할 필드명 (예: emp_id, user_id)
|
||||||
|
autoLink?: boolean; // 자동 연결 여부 (현재 레코드와 자동 연결)
|
||||||
|
recordId?: string; // 연결할 레코드 ID
|
||||||
|
|
||||||
|
// 가상 파일 컬럼 전용 설정
|
||||||
|
columnName?: string; // 가상 파일 컬럼명 (tableName:recordId:columnName 형태로 target_objid 생성)
|
||||||
|
isVirtualFileColumn?: boolean; // 가상 파일 컬럼 여부
|
||||||
|
|
||||||
|
// 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 {
|
export interface WidgetComponent extends BaseComponent {
|
||||||
type: "widget";
|
type: "widget";
|
||||||
|
|
@ -226,7 +293,7 @@ export interface WidgetComponent extends BaseComponent {
|
||||||
// 데이터 테이블 컬럼 설정
|
// 데이터 테이블 컬럼 설정
|
||||||
export interface DataTableColumn {
|
export interface DataTableColumn {
|
||||||
id: string;
|
id: string;
|
||||||
columnName: string; // 실제 DB 컬럼명
|
columnName: string; // 실제 DB 컬럼명 (가상 컬럼의 경우 고유 식별자)
|
||||||
label: string; // 화면에 표시될 라벨
|
label: string; // 화면에 표시될 라벨
|
||||||
widgetType: WebType; // 컬럼의 데이터 타입
|
widgetType: WebType; // 컬럼의 데이터 타입
|
||||||
gridColumns: number; // 그리드에서 차지할 컬럼 수 (1-12)
|
gridColumns: number; // 그리드에서 차지할 컬럼 수 (1-12)
|
||||||
|
|
@ -235,6 +302,15 @@ export interface DataTableColumn {
|
||||||
sortable: boolean; // 정렬 가능 여부
|
sortable: boolean; // 정렬 가능 여부
|
||||||
searchable: boolean; // 검색 대상 여부
|
searchable: boolean; // 검색 대상 여부
|
||||||
webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정
|
webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정
|
||||||
|
|
||||||
|
// 가상 파일 컬럼 관련 속성
|
||||||
|
isVirtualFileColumn?: boolean; // 가상 파일 컬럼인지 여부
|
||||||
|
fileColumnConfig?: {
|
||||||
|
docType?: string; // 문서 타입 (CONTRACT, DRAWING, PHOTO 등)
|
||||||
|
docTypeName?: string; // 문서 타입 표시명
|
||||||
|
maxFiles?: number; // 최대 파일 개수
|
||||||
|
accept?: string[]; // 허용 파일 타입
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 테이블 필터 설정
|
// 데이터 테이블 필터 설정
|
||||||
|
|
@ -336,7 +412,8 @@ export type ComponentData =
|
||||||
| RowComponent
|
| RowComponent
|
||||||
| ColumnComponent
|
| ColumnComponent
|
||||||
| WidgetComponent
|
| WidgetComponent
|
||||||
| DataTableComponent;
|
| DataTableComponent
|
||||||
|
| FileComponent;
|
||||||
|
|
||||||
// 레이아웃 데이터
|
// 레이아웃 데이터
|
||||||
export interface LayoutData {
|
export interface LayoutData {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue