Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into fix/429error

This commit is contained in:
dohyeons 2025-09-29 17:30:44 +09:00
commit e74deb7c34
47 changed files with 3603 additions and 729 deletions

View File

@ -111,6 +111,10 @@ model batch_mappings {
from_api_url String? @db.VarChar(500)
from_api_key String? @db.VarChar(200)
from_api_method String? @db.VarChar(10)
from_api_param_type String? @db.VarChar(10) // 'url' 또는 'query'
from_api_param_name String? @db.VarChar(100) // 파라미터명
from_api_param_value String? @db.VarChar(500) // 파라미터 값 또는 템플릿
from_api_param_source String? @db.VarChar(10) // 'static' 또는 'dynamic'
to_connection_type String @db.VarChar(20)
to_connection_id Int?
to_table_name String @db.VarChar(100)

View File

@ -52,7 +52,14 @@ import { BatchSchedulerService } from "./services/batchSchedulerService";
const app = express();
// 기본 미들웨어
app.use(helmet());
app.use(helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
"frame-ancestors": ["'self'", "http://localhost:9771", "http://localhost:3000"], // 프론트엔드 도메인 허용
},
},
}));
app.use(compression());
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));

View File

@ -3,6 +3,7 @@
import { Request, Response } from "express";
import { BatchService } from "../services/batchService";
import { BatchSchedulerService } from "../services/batchSchedulerService";
import { BatchConfigFilter, CreateBatchConfigRequest, UpdateBatchConfigRequest } from "../types/batchTypes";
export interface AuthenticatedRequest extends Request {
@ -190,6 +191,11 @@ export class BatchController {
cronSchedule,
mappings
} as CreateBatchConfigRequest);
// 생성된 배치가 활성화 상태라면 스케줄러에 등록 (즉시 실행 비활성화)
if (batchConfig.data && batchConfig.data.is_active === 'Y' && batchConfig.data.id) {
await BatchSchedulerService.updateBatchSchedule(batchConfig.data.id, false);
}
return res.status(201).json({
success: true,
@ -235,6 +241,9 @@ export class BatchController {
message: "배치 설정을 찾을 수 없습니다."
});
}
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
return res.json({
success: true,

View File

@ -224,18 +224,15 @@ export class BatchManagementController {
}
const batchConfig = batchConfigResult.data as BatchConfig;
// 배치 실행 로직 (간단한 버전)
const startTime = new Date();
let totalRecords = 0;
let successRecords = 0;
let failedRecords = 0;
console.log(`배치 수동 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
let executionLog: any = null;
try {
console.log(`배치 실행 시작: ${batchConfig.batch_name} (ID: ${id})`);
// 실행 로그 생성
const executionLog = await BatchService.createExecutionLog({
executionLog = await BatchService.createExecutionLog({
batch_config_id: Number(id),
execution_status: 'RUNNING',
start_time: startTime,
@ -244,199 +241,74 @@ export class BatchManagementController {
failed_records: 0
});
// 실제 배치 실행 (매핑이 있는 경우)
if (batchConfig.batch_mappings && batchConfig.batch_mappings.length > 0) {
// 테이블별로 매핑을 그룹화
const tableGroups = new Map<string, typeof batchConfig.batch_mappings>();
for (const mapping of batchConfig.batch_mappings) {
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
if (!tableGroups.has(key)) {
tableGroups.set(key, []);
}
tableGroups.get(key)!.push(mapping);
}
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
const { BatchSchedulerService } = await import('../services/batchSchedulerService');
const result = await BatchSchedulerService.executeBatchConfig(batchConfig);
// 각 테이블 그룹별로 처리
for (const [tableKey, mappings] of tableGroups) {
try {
const firstMapping = mappings[0];
console.log(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
let fromData: any[] = [];
// FROM 데이터 조회 (DB 또는 REST API)
if (firstMapping.from_connection_type === 'restapi') {
// REST API에서 데이터 조회
console.log(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
console.log(`API 설정:`, {
url: firstMapping.from_api_url,
key: firstMapping.from_api_key ? '***' : 'null',
method: firstMapping.from_api_method,
endpoint: firstMapping.from_table_name
});
try {
const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!,
firstMapping.from_api_key!,
firstMapping.from_table_name,
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
mappings.map(m => m.from_column_name)
);
console.log(`API 조회 결과:`, {
success: apiResult.success,
dataCount: apiResult.data ? apiResult.data.length : 0,
message: apiResult.message
});
if (apiResult.success && apiResult.data) {
fromData = apiResult.data;
} else {
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
}
} catch (error) {
console.error(`REST API 조회 오류:`, error);
throw error;
}
} else {
// DB에서 데이터 조회
const fromColumns = mappings.map(m => m.from_column_name);
fromData = await BatchService.getDataFromTableWithColumns(
firstMapping.from_table_name,
fromColumns,
firstMapping.from_connection_type as 'internal' | 'external',
firstMapping.from_connection_id || undefined
);
}
totalRecords += fromData.length;
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
const mappedData = fromData.map(row => {
const mappedRow: any = {};
for (const mapping of mappings) {
// DB → REST API 배치인지 확인
if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) {
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
mappedRow[mapping.from_column_name] = row[mapping.from_column_name];
} else {
// 기존 로직: to_column_name을 키로 사용
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
}
}
return mappedRow;
});
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
let insertResult: { successCount: number; failedCount: number };
if (firstMapping.to_connection_type === 'restapi') {
// REST API로 데이터 전송
console.log(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
const hasTemplate = mappings.some(m => m.to_api_body);
if (hasTemplate) {
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
const templateBody = firstMapping.to_api_body || '{}';
console.log(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
// URL 경로 컬럼 찾기 (PUT/DELETE용)
const urlPathColumn = mappings.find(m => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name;
const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST',
templateBody,
mappedData,
urlPathColumn
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`);
}
} else {
// 기존 REST API 전송 (REST API → DB 배치)
const apiResult = await BatchExternalDbService.sendDataToRestApi(
firstMapping.to_api_url!,
firstMapping.to_api_key!,
firstMapping.to_table_name,
firstMapping.to_api_method as 'POST' | 'PUT' || 'POST',
mappedData
);
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`);
}
}
} else {
// DB에 데이터 삽입
insertResult = await BatchService.insertDataToTable(
firstMapping.to_table_name,
mappedData,
firstMapping.to_connection_type as 'internal' | 'external',
firstMapping.to_connection_id || undefined
);
}
successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount;
console.log(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
} catch (error) {
console.error(`테이블 처리 실패: ${tableKey}`, error);
failedRecords += 1;
}
}
} else {
console.log("매핑이 없어서 데이터 처리를 건너뜁니다.");
// result가 undefined인 경우 처리
if (!result) {
throw new Error('배치 실행 결과를 받을 수 없습니다.');
}
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
// 실행 로그 업데이트 (성공)
await BatchService.updateExecutionLog(executionLog.id, {
execution_status: 'SUCCESS',
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
total_records: totalRecords,
success_records: successRecords,
failed_records: failedRecords
end_time: endTime,
duration_ms: duration,
total_records: result.totalRecords,
success_records: result.successRecords,
failed_records: result.failedRecords
});
return res.json({
success: true,
message: "배치가 성공적으로 실행되었습니다.",
data: {
batchId: id,
totalRecords,
successRecords,
failedRecords,
duration: Date.now() - startTime.getTime()
}
batchName: batchConfig.batch_name,
totalRecords: result.totalRecords,
successRecords: result.successRecords,
failedRecords: result.failedRecords,
executionTime: duration
},
message: "배치가 성공적으로 실행되었습니다."
});
} catch (error) {
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, error);
} catch (batchError) {
console.error(`배치 실행 실패: ${batchConfig.batch_name}`, batchError);
// 실행 로그 업데이트 (실패) - executionLog가 생성되었을 경우에만
try {
const endTime = new Date();
const duration = endTime.getTime() - startTime.getTime();
// executionLog가 정의되어 있는지 확인
if (typeof executionLog !== 'undefined') {
await BatchService.updateExecutionLog(executionLog.id, {
execution_status: 'FAILED',
end_time: endTime,
duration_ms: duration,
error_message: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
});
}
} catch (logError) {
console.error('실행 로그 업데이트 실패:', logError);
}
return res.status(500).json({
success: false,
message: "배치 실행에 실패했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
error: batchError instanceof Error ? batchError.message : "알 수 없는 오류"
});
}
} catch (error) {
console.error("배치 실행 오류:", error);
console.error(`배치 실행 오류 (ID: ${req.params.id}):`, error);
return res.status(500).json({
success: false,
message: "배치 실행 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류"
error: error instanceof Error ? error.message : "Unknown error"
});
}
}
@ -459,8 +331,8 @@ export class BatchManagementController {
const batchConfig = await BatchService.updateBatchConfig(Number(id), updateData);
// 스케줄러에서 배치 스케줄 업데이트
await BatchSchedulerService.updateBatchSchedule(Number(id));
// 스케줄러에서 배치 스케줄 업데이트 (즉시 실행 비활성화)
await BatchSchedulerService.updateBatchSchedule(Number(id), false);
return res.json({
success: true,
@ -482,7 +354,16 @@ export class BatchManagementController {
*/
static async previewRestApiData(req: AuthenticatedRequest, res: Response) {
try {
const { apiUrl, apiKey, endpoint, method = 'GET' } = req.body;
const {
apiUrl,
apiKey,
endpoint,
method = 'GET',
paramType,
paramName,
paramValue,
paramSource
} = req.body;
if (!apiUrl || !apiKey || !endpoint) {
return res.status(400).json({
@ -491,6 +372,15 @@ export class BatchManagementController {
});
}
console.log("🔍 REST API 미리보기 요청:", {
apiUrl,
endpoint,
paramType,
paramName,
paramValue,
paramSource
});
// RestApiConnector 사용하여 데이터 조회
const { RestApiConnector } = await import('../database/RestApiConnector');
@ -503,8 +393,28 @@ export class BatchManagementController {
// 연결 테스트
await connector.connect();
// 파라미터가 있는 경우 엔드포인트 수정
let finalEndpoint = endpoint;
if (paramType && paramName && paramValue) {
if (paramType === 'url') {
// URL 파라미터: /api/users/{userId} → /api/users/123
if (endpoint.includes(`{${paramName}}`)) {
finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue);
} else {
// 엔드포인트에 {paramName}이 없으면 뒤에 추가
finalEndpoint = `${endpoint}/${paramValue}`;
}
} else if (paramType === 'query') {
// 쿼리 파라미터: /api/users?userId=123
const separator = endpoint.includes('?') ? '&' : '?';
finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`;
}
}
console.log("🔗 최종 엔드포인트:", finalEndpoint);
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
const result = await connector.executeQuery(endpoint, method);
const result = await connector.executeQuery(finalEndpoint, method);
console.log(`[previewRestApiData] executeQuery 결과:`, {
rowCount: result.rowCount,
rowsLength: result.rows ? result.rows.length : 'undefined',

View File

@ -8,6 +8,9 @@ import { generateUUID } from "../utils/generateId";
const prisma = new PrismaClient();
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
const tempTokens = new Map<string, { objid: string; expires: number }>();
// 업로드 디렉토리 설정 (회사별로 분리)
const baseUploadDir = path.join(process.cwd(), "uploads");
@ -266,9 +269,7 @@ export const uploadFiles = async (
// 회사코드가 *인 경우 company_*로 변환
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
const fullFilePath = `/uploads${relativePath}`;
// 임시 파일을 최종 위치로 이동
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
@ -277,6 +278,10 @@ export const uploadFiles = async (
// 파일 이동
fs.renameSync(tempFilePath, finalFilePath);
// DB에 저장할 경로 (실제 파일 위치와 일치)
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
const fullFilePath = `/uploads${relativePath}`;
// attach_file_info 테이블에 저장
const fileRecord = await prisma.attach_file_info.create({
data: {
@ -485,6 +490,133 @@ export const getFileList = async (
}
};
/**
* 릿
*/
export const getComponentFiles = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, componentId, tableName, recordId, columnName } = req.query;
console.log("📂 [getComponentFiles] API 호출:", {
screenId,
componentId,
tableName,
recordId,
columnName,
user: req.user?.userId
});
if (!screenId || !componentId) {
console.log("❌ [getComponentFiles] 필수 파라미터 누락");
res.status(400).json({
success: false,
message: "screenId와 componentId가 필요합니다.",
});
return;
}
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || 'field_1'}`;
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid });
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
const allFiles = await prisma.attach_file_info.findMany({
where: {
status: "ACTIVE",
},
select: {
target_objid: true,
real_file_name: true,
regdate: true,
},
orderBy: {
regdate: "desc",
},
take: 10,
});
console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name })));
const templateFiles = await prisma.attach_file_info.findMany({
where: {
target_objid: templateTargetObjid,
status: "ACTIVE",
},
orderBy: {
regdate: "desc",
},
});
console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length);
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
let dataFiles: any[] = [];
if (tableName && recordId && columnName) {
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
dataFiles = await prisma.attach_file_info.findMany({
where: {
target_objid: dataTargetObjid,
status: "ACTIVE",
},
orderBy: {
regdate: "desc",
},
});
}
// 파일 정보 포맷팅 함수
const formatFileInfo = (file: any, isTemplate: boolean = false) => ({
objid: file.objid.toString(),
savedFileName: file.saved_file_name,
realFileName: file.real_file_name,
fileSize: Number(file.file_size),
fileExt: file.file_ext,
filePath: file.file_path,
docType: file.doc_type,
docTypeName: file.doc_type_name,
targetObjid: file.target_objid,
parentTargetObjid: file.parent_target_objid,
writer: file.writer,
regdate: file.regdate?.toISOString(),
status: file.status,
isTemplate, // 템플릿 파일 여부 표시
});
const formattedTemplateFiles = templateFiles.map(file => formatFileInfo(file, true));
const formattedDataFiles = dataFiles.map(file => formatFileInfo(file, false));
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
const totalFiles = formattedDataFiles.length > 0
? formattedDataFiles
: formattedTemplateFiles;
res.json({
success: true,
templateFiles: formattedTemplateFiles,
dataFiles: formattedDataFiles,
totalFiles,
summary: {
templateCount: formattedTemplateFiles.length,
dataCount: formattedDataFiles.length,
totalCount: totalFiles.length,
templateTargetObjid,
dataTargetObjid: tableName && recordId && columnName
? `${tableName}:${recordId}:${columnName}`
: null,
},
});
} catch (error) {
console.error("컴포넌트 파일 조회 오류:", error);
res.status(500).json({
success: false,
message: "컴포넌트 파일 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
};
/**
* ( )
*/
@ -512,7 +644,13 @@ export const previewFile = async (
// 파일 경로에서 회사코드와 날짜 폴더 추출
const filePathParts = fileRecord.file_path!.split("/");
const companyCode = filePathParts[2] || "DEFAULT";
let companyCode = filePathParts[2] || "DEFAULT";
// company_* 처리 (실제 회사 코드로 변환)
if (companyCode === "company_*") {
companyCode = "company_*"; // 실제 디렉토리명 유지
}
const fileName = fileRecord.saved_file_name!;
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
@ -527,6 +665,17 @@ export const previewFile = async (
);
const filePath = path.join(companyUploadDir, fileName);
console.log("🔍 파일 미리보기 경로 확인:", {
objid: objid,
filePathFromDB: fileRecord.file_path,
companyCode: companyCode,
dateFolder: dateFolder,
fileName: fileName,
companyUploadDir: companyUploadDir,
finalFilePath: filePath,
fileExists: fs.existsSync(filePath)
});
if (!fs.existsSync(filePath)) {
console.error("❌ 파일 없음:", filePath);
res.status(404).json({
@ -615,7 +764,13 @@ export const downloadFile = async (
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
const filePathParts = fileRecord.file_path!.split("/");
const companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
let companyCode = filePathParts[2] || "DEFAULT"; // /uploads/company_*/2025/09/05/filename.ext에서 company_* 추출
// company_* 처리 (실제 회사 코드로 변환)
if (companyCode === "company_*") {
companyCode = "company_*"; // 실제 디렉토리명 유지
}
const fileName = fileRecord.saved_file_name!;
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
@ -631,6 +786,17 @@ export const downloadFile = async (
);
const filePath = path.join(companyUploadDir, fileName);
console.log("🔍 파일 다운로드 경로 확인:", {
objid: objid,
filePathFromDB: fileRecord.file_path,
companyCode: companyCode,
dateFolder: dateFolder,
fileName: fileName,
companyUploadDir: companyUploadDir,
finalFilePath: filePath,
fileExists: fs.existsSync(filePath)
});
if (!fs.existsSync(filePath)) {
console.error("❌ 파일 없음:", filePath);
res.status(404).json({
@ -660,5 +826,178 @@ export const downloadFile = async (
}
};
/**
* Google Docs Viewer용
*/
export const generateTempToken = async (req: AuthenticatedRequest, res: Response) => {
try {
const { objid } = req.params;
if (!objid) {
res.status(400).json({
success: false,
message: "파일 ID가 필요합니다.",
});
return;
}
// 파일 존재 확인
const fileRecord = await prisma.attach_file_info.findUnique({
where: { objid: objid },
});
if (!fileRecord) {
res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
return;
}
// 임시 토큰 생성 (30분 유효)
const token = generateUUID();
const expires = Date.now() + 30 * 60 * 1000; // 30분
tempTokens.set(token, {
objid: objid,
expires: expires,
});
// 만료된 토큰 정리 (메모리 누수 방지)
const now = Date.now();
for (const [key, value] of tempTokens.entries()) {
if (value.expires < now) {
tempTokens.delete(key);
}
}
res.json({
success: true,
data: {
token: token,
publicUrl: `${req.protocol}://${req.get("host")}/api/files/public/${token}`,
expires: new Date(expires).toISOString(),
},
});
} catch (error) {
console.error("❌ 임시 토큰 생성 오류:", error);
res.status(500).json({
success: false,
message: "임시 토큰 생성에 실패했습니다.",
});
}
};
/**
* ( )
*/
export const getFileByToken = async (req: Request, res: Response) => {
try {
const { token } = req.params;
if (!token) {
res.status(400).json({
success: false,
message: "토큰이 필요합니다.",
});
return;
}
// 토큰 확인
const tokenData = tempTokens.get(token);
if (!tokenData) {
res.status(404).json({
success: false,
message: "유효하지 않은 토큰입니다.",
});
return;
}
// 토큰 만료 확인
if (tokenData.expires < Date.now()) {
tempTokens.delete(token);
res.status(410).json({
success: false,
message: "토큰이 만료되었습니다.",
});
return;
}
// 파일 정보 조회
const fileRecord = await prisma.attach_file_info.findUnique({
where: { objid: tokenData.objid },
});
if (!fileRecord) {
res.status(404).json({
success: false,
message: "파일을 찾을 수 없습니다.",
});
return;
}
// 파일 경로 구성
const filePathParts = fileRecord.file_path!.split("/");
let companyCode = filePathParts[2] || "DEFAULT";
if (companyCode === "company_*") {
companyCode = "company_*"; // 실제 디렉토리명 유지
}
const fileName = fileRecord.saved_file_name!;
let dateFolder = "";
if (filePathParts.length >= 6) {
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
}
const companyUploadDir = getCompanyUploadDir(companyCode, dateFolder || undefined);
const filePath = path.join(companyUploadDir, fileName);
// 파일 존재 확인
if (!fs.existsSync(filePath)) {
res.status(404).json({
success: false,
message: "실제 파일을 찾을 수 없습니다.",
});
return;
}
// MIME 타입 설정
const ext = path.extname(fileName).toLowerCase();
let contentType = "application/octet-stream";
const mimeTypes: { [key: string]: string } = {
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".txt": "text/plain",
};
if (mimeTypes[ext]) {
contentType = mimeTypes[ext];
}
// 파일 헤더 설정
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`);
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
// 파일 스트림 전송
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
} catch (error) {
console.error("❌ 토큰 파일 접근 오류:", error);
res.status(500).json({
success: false,
message: "파일 접근에 실패했습니다.",
});
}
};
// Multer 미들웨어 export
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일

View File

@ -27,7 +27,7 @@ export class RestApiConnector implements DatabaseConnector {
timeout: config.timeout || 30000,
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.apiKey,
'Authorization': `Bearer ${config.apiKey}`,
'Accept': 'application/json'
}
});

View File

@ -3,15 +3,26 @@ import {
uploadFiles,
deleteFile,
getFileList,
getComponentFiles,
downloadFile,
previewFile,
getLinkedFiles,
uploadMiddleware,
generateTempToken,
getFileByToken,
} from "../controllers/fileController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
// 공개 접근 라우트 (인증 불필요)
/**
* @route GET /api/files/public/:token
* @desc (Google Docs Viewer용)
* @access Public
*/
router.get("/public/:token", getFileByToken);
// 모든 파일 API는 인증 필요
router.use(authenticateToken);
@ -30,6 +41,14 @@ router.post("/upload", uploadMiddleware, uploadFiles);
*/
router.get("/", getFileList);
/**
* @route GET /api/files/component-files
* @desc 릿
* @query screenId, componentId, tableName, recordId, columnName
* @access Private
*/
router.get("/component-files", getComponentFiles);
/**
* @route GET /api/files/linked/:tableName/:recordId
* @desc
@ -58,4 +77,11 @@ router.get("/preview/:objid", previewFile);
*/
router.get("/download/:objid", downloadFile);
/**
* @route POST /api/files/temp-token/:objid
* @desc Google Docs Viewer용
* @access Private
*/
router.post("/temp-token/:objid", generateTempToken);
export default router;

View File

@ -697,7 +697,12 @@ export class BatchExternalDbService {
endpoint: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
columns?: string[],
limit: number = 100
limit: number = 100,
// 파라미터 정보 추가
paramType?: 'url' | 'query',
paramName?: string,
paramValue?: string,
paramSource?: 'static' | 'dynamic'
): Promise<ApiResponse<any[]>> {
try {
console.log(`[BatchExternalDbService] REST API 데이터 조회: ${apiUrl}${endpoint}`);
@ -712,8 +717,33 @@ export class BatchExternalDbService {
// 연결 테스트
await connector.connect();
// 파라미터가 있는 경우 엔드포인트 수정
const { logger } = await import('../utils/logger');
logger.info(`[BatchExternalDbService] 파라미터 정보`, {
paramType, paramName, paramValue, paramSource
});
let finalEndpoint = endpoint;
if (paramType && paramName && paramValue) {
if (paramType === 'url') {
// URL 파라미터: /api/users/{userId} → /api/users/123
if (endpoint.includes(`{${paramName}}`)) {
finalEndpoint = endpoint.replace(`{${paramName}}`, paramValue);
} else {
// 엔드포인트에 {paramName}이 없으면 뒤에 추가
finalEndpoint = `${endpoint}/${paramValue}`;
}
} else if (paramType === 'query') {
// 쿼리 파라미터: /api/users?userId=123
const separator = endpoint.includes('?') ? '&' : '?';
finalEndpoint = `${endpoint}${separator}${paramName}=${paramValue}`;
}
logger.info(`[BatchExternalDbService] 파라미터 적용된 엔드포인트: ${finalEndpoint}`);
}
// 데이터 조회
const result = await connector.executeQuery(endpoint, method);
const result = await connector.executeQuery(finalEndpoint, method);
let data = result.rows;
// 컬럼 필터링 (지정된 컬럼만 추출)
@ -734,7 +764,8 @@ export class BatchExternalDbService {
data = data.slice(0, limit);
}
console.log(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`);
logger.info(`[BatchExternalDbService] REST API 데이터 조회 완료: ${data.length}개 레코드`);
logger.info(`[BatchExternalDbService] 조회된 데이터`, { data });
return {
success: true,

View File

@ -10,19 +10,18 @@ import { logger } from '../utils/logger';
export class BatchSchedulerService {
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
private static isInitialized = false;
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
/**
*
*/
static async initialize() {
if (this.isInitialized) {
logger.info('배치 스케줄러가 이미 초기화되었습니다.');
return;
}
try {
logger.info('배치 스케줄러 초기화 시작...');
// 기존 모든 스케줄 정리 (중복 방지)
this.clearAllSchedules();
// 활성화된 배치 설정들을 로드하여 스케줄 등록
await this.loadActiveBatchConfigs();
@ -34,6 +33,27 @@ export class BatchSchedulerService {
}
}
/**
*
*/
private static clearAllSchedules() {
logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`);
for (const [id, task] of this.scheduledTasks) {
try {
task.stop();
task.destroy();
logger.info(`스케줄 정리 완료: ID ${id}`);
} catch (error) {
logger.error(`스케줄 정리 실패: ID ${id}`, error);
}
}
this.scheduledTasks.clear();
this.isInitialized = false;
logger.info('모든 스케줄 정리 완료');
}
/**
*
*/
@ -80,8 +100,23 @@ export class BatchSchedulerService {
// 새로운 스케줄 등록
const task = cron.schedule(cron_schedule, async () => {
// 중복 실행 방지 체크
if (this.executingBatches.has(id)) {
logger.warn(`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`);
return;
}
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
await this.executeBatchConfig(config);
// 실행 중 플래그 설정
this.executingBatches.add(id);
try {
await this.executeBatchConfig(config);
} finally {
// 실행 완료 후 플래그 제거
this.executingBatches.delete(id);
}
});
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
@ -112,7 +147,7 @@ export class BatchSchedulerService {
/**
*
*/
static async updateBatchSchedule(configId: number) {
static async updateBatchSchedule(configId: number, executeImmediately: boolean = true) {
try {
// 기존 스케줄 제거
await this.unscheduleBatchConfig(configId);
@ -132,6 +167,12 @@ export class BatchSchedulerService {
if (config.is_active === 'Y') {
await this.scheduleBatchConfig(config);
logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`);
// 활성화 시 즉시 실행 (옵션)
if (executeImmediately) {
logger.info(`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`);
await this.executeBatchConfig(config);
}
} else {
logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`);
}
@ -143,7 +184,7 @@ export class BatchSchedulerService {
/**
*
*/
private static async executeBatchConfig(config: any) {
static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
@ -162,7 +203,11 @@ export class BatchSchedulerService {
if (!executionLogResponse.success || !executionLogResponse.data) {
logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message);
return;
return {
totalRecords: 0,
successRecords: 0,
failedRecords: 1
};
}
executionLog = executionLogResponse.data;
@ -181,6 +226,10 @@ export class BatchSchedulerService {
});
logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`);
// 성공 결과 반환
return result;
} catch (error) {
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
@ -194,6 +243,13 @@ export class BatchSchedulerService {
error_details: error instanceof Error ? error.stack : String(error)
});
}
// 실패 시에도 결과 반환
return {
totalRecords: 0,
successRecords: 0,
failedRecords: 1
};
}
}
@ -239,7 +295,13 @@ export class BatchSchedulerService {
firstMapping.from_api_key!,
firstMapping.from_table_name,
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
mappings.map((m: any) => m.from_column_name)
mappings.map((m: any) => m.from_column_name),
100, // limit
// 파라미터 정보 전달
firstMapping.from_api_param_type,
firstMapping.from_api_param_name,
firstMapping.from_api_param_value,
firstMapping.from_api_param_source
);
if (apiResult.success && apiResult.data) {

View File

@ -168,6 +168,10 @@ export class BatchService {
from_api_url: mapping.from_api_url,
from_api_key: mapping.from_api_key,
from_api_method: mapping.from_api_method,
from_api_param_type: mapping.from_api_param_type,
from_api_param_name: mapping.from_api_param_name,
from_api_param_value: mapping.from_api_param_value,
from_api_param_source: mapping.from_api_param_source,
to_connection_type: mapping.to_connection_type,
to_connection_id: mapping.to_connection_id,
to_table_name: mapping.to_table_name,
@ -176,7 +180,7 @@ export class BatchService {
to_api_url: mapping.to_api_url,
to_api_key: mapping.to_api_key,
to_api_method: mapping.to_api_method,
// to_api_body: mapping.to_api_body, // Request Body 템플릿 추가 - 임시 주석 처리
to_api_body: mapping.to_api_body,
mapping_order: mapping.mapping_order || index + 1,
created_by: userId,
},
@ -260,11 +264,22 @@ export class BatchService {
from_table_name: mapping.from_table_name,
from_column_name: mapping.from_column_name,
from_column_type: mapping.from_column_type,
from_api_url: mapping.from_api_url,
from_api_key: mapping.from_api_key,
from_api_method: mapping.from_api_method,
from_api_param_type: mapping.from_api_param_type,
from_api_param_name: mapping.from_api_param_name,
from_api_param_value: mapping.from_api_param_value,
from_api_param_source: mapping.from_api_param_source,
to_connection_type: mapping.to_connection_type,
to_connection_id: mapping.to_connection_id,
to_table_name: mapping.to_table_name,
to_column_name: mapping.to_column_name,
to_column_type: mapping.to_column_type,
to_api_url: mapping.to_api_url,
to_api_key: mapping.to_api_key,
to_api_method: mapping.to_api_method,
to_api_body: mapping.to_api_body,
mapping_order: mapping.mapping_order || index + 1,
created_by: userId,
},
@ -707,18 +722,57 @@ export class BatchService {
const updateColumns = columns.filter(col => col !== primaryKeyColumn);
const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', ');
let query: string;
if (updateSet) {
// UPSERT: 중복 시 업데이트
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})
ON CONFLICT (${primaryKeyColumn}) DO UPDATE SET ${updateSet}`;
} else {
// Primary Key만 있는 경우 중복 시 무시
query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})
ON CONFLICT (${primaryKeyColumn}) DO NOTHING`;
}
// 트랜잭션 내에서 처리하여 연결 관리 최적화
const result = await prisma.$transaction(async (tx) => {
// 먼저 해당 레코드가 존재하는지 확인
const checkQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${primaryKeyColumn} = $1`;
const existsResult = await tx.$queryRawUnsafe(checkQuery, record[primaryKeyColumn]);
const exists = (existsResult as any)[0]?.count > 0;
let operationResult = 'no_change';
if (exists && updateSet) {
// 기존 레코드가 있으면 UPDATE (값이 다른 경우에만)
const whereConditions = updateColumns.map((col, index) => {
// 날짜/시간 컬럼에 대한 타입 캐스팅 처리
if (col.toLowerCase().includes('date') ||
col.toLowerCase().includes('time') ||
col.toLowerCase().includes('created') ||
col.toLowerCase().includes('updated') ||
col.toLowerCase().includes('reg')) {
return `${col} IS DISTINCT FROM $${index + 2}::timestamp`;
}
return `${col} IS DISTINCT FROM $${index + 2}`;
}).join(' OR ');
const query = `UPDATE ${tableName} SET ${updateSet.replace(/EXCLUDED\./g, '')}
WHERE ${primaryKeyColumn} = $1 AND (${whereConditions})`;
// 파라미터: [primaryKeyValue, ...updateValues]
const updateValues = [record[primaryKeyColumn], ...updateColumns.map(col => record[col])];
const updateResult = await tx.$executeRawUnsafe(query, ...updateValues);
if (updateResult > 0) {
console.log(`[BatchService] 레코드 업데이트: ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
operationResult = 'updated';
} else {
console.log(`[BatchService] 레코드 변경사항 없음: ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
operationResult = 'no_change';
}
} else if (!exists) {
// 새 레코드 삽입
const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
await tx.$executeRawUnsafe(query, ...values);
console.log(`[BatchService] 새 레코드 삽입: ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
operationResult = 'inserted';
} else {
console.log(`[BatchService] 레코드 이미 존재 (변경사항 없음): ${primaryKeyColumn}=${record[primaryKeyColumn]}`);
operationResult = 'no_change';
}
return operationResult;
});
await prisma.$executeRawUnsafe(query, ...values);
successCount++;
} catch (error) {
console.error(`레코드 UPSERT 실패:`, error);

View File

@ -37,6 +37,10 @@ export interface BatchMapping {
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
from_api_url?: string; // REST API 서버 URL
from_api_key?: string; // REST API 키
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
// TO 정보
to_connection_type: 'internal' | 'external' | 'restapi';
@ -92,6 +96,10 @@ export interface BatchMappingRequest {
from_api_url?: string;
from_api_key?: string;
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number;
to_table_name: string;

View File

@ -53,6 +53,12 @@ export default function BatchManagementNewPage() {
const [fromApiKey, setFromApiKey] = useState("");
const [fromEndpoint, setFromEndpoint] = useState("");
const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원
// REST API 파라미터 설정
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id)
const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿
const [apiParamSource, setApiParamSource] = useState<'static' | 'dynamic'>('static'); // 정적 값 또는 동적 값
// DB → REST API용 상태
const [fromConnection, setFromConnection] = useState<BatchConnectionInfo | null>(null);
@ -309,7 +315,14 @@ export default function BatchManagementNewPage() {
fromApiUrl,
fromApiKey,
fromEndpoint,
fromApiMethod
fromApiMethod,
// 파라미터 정보 추가
apiParamType !== 'none' ? {
paramType: apiParamType,
paramName: apiParamName,
paramValue: apiParamValue,
paramSource: apiParamSource
} : undefined
);
console.log("API 미리보기 결과:", result);
@ -371,6 +384,11 @@ export default function BatchManagementNewPage() {
from_api_url: fromApiUrl,
from_api_key: fromApiKey,
from_api_method: fromApiMethod,
// API 파라미터 정보 추가
from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined,
from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined,
from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined,
from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined,
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
to_table_name: toTable,
@ -661,14 +679,119 @@ export default function BatchManagementNewPage() {
</div>
{/* API 파라미터 설정 */}
<div className="space-y-4">
<div className="border-t pt-4">
<Label className="text-base font-medium">API </Label>
<p className="text-sm text-gray-600 mt-1"> .</p>
</div>
<div>
<Label> </Label>
<Select value={apiParamType} onValueChange={(value: any) => setApiParamType(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="url">URL (/api/users/{`{userId}`})</SelectItem>
<SelectItem value="query"> (/api/users?userId=123)</SelectItem>
</SelectContent>
</Select>
</div>
{apiParamType !== 'none' && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="apiParamName"> *</Label>
<Input
id="apiParamName"
value={apiParamName}
onChange={(e) => setApiParamName(e.target.value)}
placeholder="userId, id, email 등"
/>
</div>
<div>
<Label> </Label>
<Select value={apiParamSource} onValueChange={(value: any) => setApiParamSource(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"></SelectItem>
<SelectItem value="dynamic"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="apiParamValue">
{apiParamSource === 'static' ? '파라미터 값' : '파라미터 템플릿'} *
</Label>
<Input
id="apiParamValue"
value={apiParamValue}
onChange={(e) => setApiParamValue(e.target.value)}
placeholder={
apiParamSource === 'static'
? "123, john@example.com 등"
: "{{user_id}}, {{email}} 등 (실행 시 치환됨)"
}
/>
{apiParamSource === 'dynamic' && (
<p className="text-xs text-gray-500 mt-1">
. : {`{{user_id}}`} ID
</p>
)}
</div>
{apiParamType === 'url' && (
<div className="p-3 bg-blue-50 rounded-lg">
<div className="text-sm font-medium text-blue-800">URL </div>
<div className="text-sm text-blue-700 mt-1">
: /api/users/{`{${apiParamName || 'userId'}}`}
</div>
<div className="text-sm text-blue-700">
: /api/users/{apiParamValue || '123'}
</div>
</div>
)}
{apiParamType === 'query' && (
<div className="p-3 bg-green-50 rounded-lg">
<div className="text-sm font-medium text-green-800"> </div>
<div className="text-sm text-green-700 mt-1">
: {fromEndpoint || '/api/users'}?{apiParamName || 'userId'}={apiParamValue || '123'}
</div>
</div>
)}
</>
)}
</div>
{fromApiUrl && fromApiKey && fromEndpoint && (
<div className="space-y-3">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700">API </div>
<div className="text-sm text-gray-600 mt-1">
{fromApiMethod} {fromApiUrl}{fromEndpoint}
{fromApiMethod} {fromApiUrl}
{apiParamType === 'url' && apiParamName && apiParamValue
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
: fromEndpoint
}
{apiParamType === 'query' && apiParamName && apiParamValue
? `?${apiParamName}=${apiParamValue}`
: ''
}
</div>
<div className="text-xs text-gray-500 mt-1">Headers: X-API-Key: {fromApiKey.substring(0, 10)}...</div>
{apiParamType !== 'none' && apiParamName && apiParamValue && (
<div className="text-xs text-blue-600 mt-1">
: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
</div>
)}
</div>
<Button onClick={previewRestApiData} variant="outline" className="w-full">
<RefreshCw className="w-4 h-4 mr-2" />

View File

@ -66,6 +66,7 @@ export default function BatchEditPage() {
// 배치 타입 감지
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
// 페이지 로드 시 배치 정보 조회
useEffect(() => {
if (batchId) {
@ -342,9 +343,11 @@ export default function BatchEditPage() {
// 매핑 업데이트
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
const updatedMappings = [...mappings];
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
setMappings(updatedMappings);
setMappings(prevMappings => {
const updatedMappings = [...prevMappings];
updatedMappings[index] = { ...updatedMappings[index], [field]: value };
return updatedMappings;
});
};
// 배치 설정 저장

View File

@ -2,6 +2,7 @@ import {
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
} from "lucide-react";
import Link from "next/link";
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
/**
*
@ -199,6 +200,16 @@ export default function AdminPage() {
</Link>
</div>
</div>
{/* 전역 파일 관리 */}
<div className="mx-auto max-w-7xl space-y-6">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-900 mb-2"> </h2>
<p className="text-gray-600"> </p>
</div>
<GlobalFileViewer />
</div>
</div>
</div>
);

View File

@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
import { EditModal } from "@/components/screen/EditModal";
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
export default function ScreenViewPage() {
@ -116,10 +117,10 @@ export default function ScreenViewPage() {
if (loading) {
return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
<p className="mt-2 text-gray-600"> ...</p>
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8">
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-600" />
<p className="mt-4 text-gray-700 font-medium"> ...</p>
</div>
</div>
);
@ -127,14 +128,14 @@ export default function ScreenViewPage() {
if (error || !screen) {
return (
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<span className="text-2xl"></span>
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8 max-w-md">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-red-100 to-orange-100 shadow-sm">
<span className="text-3xl"></span>
</div>
<h2 className="mb-2 text-xl font-semibold text-gray-900"> </h2>
<p className="mb-4 text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
<Button onClick={() => router.back()} variant="outline">
<h2 className="mb-3 text-xl font-bold text-gray-900"> </h2>
<p className="mb-6 text-gray-600 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
</Button>
</div>
@ -147,17 +148,17 @@ export default function ScreenViewPage() {
const screenHeight = layout?.screenResolution?.height || 800;
return (
<div className="h-full w-full overflow-auto bg-white pt-10">
<div className="h-full w-full overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 pt-10">
{layout && layout.components.length > 0 ? (
// 캔버스 컴포넌트들을 정확한 해상도로 표시
<div
className="relative bg-white"
className="relative bg-white rounded-xl border border-gray-200/60 shadow-lg shadow-gray-900/5 mx-auto"
style={{
width: `${screenWidth}px`,
height: `${screenHeight}px`,
minWidth: `${screenWidth}px`,
minHeight: `${screenHeight}px`,
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
margin: "0 auto 40px auto", // 하단 여백 추가
}}
>
{layout.components
@ -177,15 +178,16 @@ export default function ScreenViewPage() {
width: `${component.size.width}px`,
height: `${component.size.height}px`,
zIndex: component.position.z || 1,
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
border: (component as any).border || "2px dashed #3b82f6",
borderRadius: (component as any).borderRadius || "8px",
padding: "16px",
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.05)",
border: (component as any).border || "1px solid rgba(59, 130, 246, 0.2)",
borderRadius: (component as any).borderRadius || "12px",
padding: "20px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
}}
>
{/* 그룹 제목 */}
{(component as any).title && (
<div className="mb-2 text-sm font-medium text-blue-700">{(component as any).title}</div>
<div className="mb-3 text-sm font-semibold text-blue-700 bg-blue-50 px-3 py-1 rounded-lg inline-block">{(component as any).title}</div>
)}
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
@ -324,7 +326,19 @@ export default function ScreenViewPage() {
/>
) : (
<DynamicWebTypeRenderer
webType={component.webType || "text"}
webType={(() => {
// 유틸리티 함수로 파일 컴포넌트 감지
if (isFileComponent(component)) {
console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, {
componentId: component.id,
componentType: component.type,
originalWebType: component.webType
});
return "file";
}
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
return getComponentWebType(component) || "text";
})()}
config={component.webTypeConfig}
props={{
component: component,

View File

@ -0,0 +1,303 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { GlobalFileManager, GlobalFileInfo } from "@/lib/api/globalFile";
import { downloadFile } from "@/lib/api/file";
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
import { formatFileSize } from "@/lib/utils";
import { toast } from "sonner";
import {
File,
FileText,
Image,
Video,
Music,
Archive,
Download,
Eye,
Search,
Trash2,
Clock,
MapPin,
Monitor,
RefreshCw,
Info,
} from "lucide-react";
interface GlobalFileViewerProps {
showControls?: boolean;
maxHeight?: string;
className?: string;
}
export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
showControls = true,
maxHeight = "600px",
className = "",
}) => {
const [allFiles, setAllFiles] = useState<GlobalFileInfo[]>([]);
const [filteredFiles, setFilteredFiles] = useState<GlobalFileInfo[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [selectedTab, setSelectedTab] = useState("all");
const [viewerFile, setViewerFile] = useState<GlobalFileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [registryInfo, setRegistryInfo] = useState({
totalFiles: 0,
accessibleFiles: 0,
pages: [] as string[],
screens: [] as number[],
});
// 파일 아이콘 가져오기
const getFileIcon = (fileName: string, size: number = 16) => {
const extension = fileName.split('.').pop()?.toLowerCase() || '';
const iconProps = { size, className: "text-gray-600" };
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) {
return <Image {...iconProps} className="text-blue-600" />;
}
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) {
return <Video {...iconProps} className="text-purple-600" />;
}
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) {
return <Music {...iconProps} className="text-green-600" />;
}
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
return <Archive {...iconProps} className="text-yellow-600" />;
}
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
return <FileText {...iconProps} className="text-red-600" />;
}
return <File {...iconProps} />;
};
// 파일 목록 새로고침
const refreshFiles = () => {
const files = GlobalFileManager.getAllAccessibleFiles();
const info = GlobalFileManager.getRegistryInfo();
setAllFiles(files);
setRegistryInfo(info);
// 탭에 따른 필터링
filterFilesByTab(files, selectedTab, searchQuery);
console.log("🔄 전역 파일 목록 새로고침:", files.length + "개");
};
// 탭별 파일 필터링
const filterFilesByTab = (files: GlobalFileInfo[], tab: string, query: string) => {
let filtered = files;
// 탭별 필터링
if (tab === "images") {
filtered = files.filter(file => {
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
});
} else if (tab === "documents") {
filtered = files.filter(file => {
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || '';
return ['txt', 'md', 'doc', 'docx', 'pdf', 'rtf', 'hwp', 'hwpx'].includes(ext);
});
} else if (tab === "recent") {
filtered = files
.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime())
.slice(0, 20);
}
// 검색 필터링
if (query.trim()) {
const lowerQuery = query.toLowerCase();
filtered = filtered.filter(file =>
file.realFileName?.toLowerCase().includes(lowerQuery) ||
file.savedFileName?.toLowerCase().includes(lowerQuery) ||
file.uploadPage?.toLowerCase().includes(lowerQuery)
);
}
setFilteredFiles(filtered);
};
// 파일 다운로드
const handleDownload = async (file: GlobalFileInfo) => {
try {
await downloadFile({
fileId: file.objid,
originalName: file.realFileName || file.savedFileName || "download",
});
toast.success(`파일 다운로드 시작: ${file.realFileName}`);
} catch (error) {
console.error("파일 다운로드 오류:", error);
toast.error("파일 다운로드에 실패했습니다.");
}
};
// 파일 뷰어 열기
const handleView = (file: GlobalFileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
};
// 파일 접근 불가능하게 설정 (삭제 대신)
const handleRemove = (file: GlobalFileInfo) => {
GlobalFileManager.setFileAccessible(file.objid, false);
refreshFiles();
toast.success(`파일이 목록에서 제거되었습니다: ${file.realFileName}`);
};
// 초기 로드 및 검색/탭 변경 시 필터링
useEffect(() => {
refreshFiles();
}, []);
useEffect(() => {
filterFilesByTab(allFiles, selectedTab, searchQuery);
}, [allFiles, selectedTab, searchQuery]);
return (
<div className={`w-full ${className}`}>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="flex items-center gap-2">
<File className="w-5 h-5" />
</CardTitle>
{showControls && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="flex items-center gap-1">
<Info className="w-3 h-3" />
{registryInfo.accessibleFiles}
</Badge>
<Button
variant="outline"
size="sm"
onClick={refreshFiles}
className="flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
</Button>
</div>
)}
</div>
{showControls && (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="파일명으로 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
)}
</CardHeader>
<CardContent>
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="recent"></TabsTrigger>
<TabsTrigger value="images"></TabsTrigger>
<TabsTrigger value="documents"></TabsTrigger>
</TabsList>
<TabsContent value={selectedTab} className="mt-4">
<div
className="space-y-2 overflow-y-auto"
style={{ maxHeight }}
>
{filteredFiles.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
</div>
) : (
filteredFiles.map((file) => (
<Card key={file.objid} className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{getFileIcon(file.realFileName || file.savedFileName || "", 20)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{file.realFileName || file.savedFileName}
</div>
<div className="text-sm text-gray-500 flex items-center gap-2">
<span>{formatFileSize(file.fileSize)}</span>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(file.uploadTime).toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{file.uploadPage.split('/').pop() || 'Unknown'}
</div>
{file.screenId && (
<div className="flex items-center gap-1">
<Monitor className="w-3 h-3" />
Screen {file.screenId}
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(file)}
className="flex items-center gap-1"
>
<Eye className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownload(file)}
className="flex items-center gap-1"
>
<Download className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(file)}
className="flex items-center gap-1 text-red-600 hover:text-red-700"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</Card>
))
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* 파일 뷰어 모달 */}
{viewerFile && (
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={() => {
setIsViewerOpen(false);
setViewerFile(null);
}}
/>
)}
</div>
);
};

View File

@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
<div
ref={panelRef}
className={cn(
"fixed z-[9998] rounded-lg border border-gray-200 bg-white shadow-lg",
"fixed z-[9998] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
isResizing && "cursor-se-resize",
className,
@ -246,7 +246,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
<div
ref={dragHandleRef}
data-header="true"
className="flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3"
className="flex cursor-move items-center justify-between rounded-t-xl border-b border-gray-200/60 bg-gradient-to-r from-gray-50 to-slate-50 p-4"
onMouseDown={handleDragStart}
style={{
userSelect: "none", // 텍스트 선택 방지
@ -259,8 +259,8 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
<GripVertical className="h-4 w-4 text-gray-400" />
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
</div>
<button onClick={onClose} className="rounded p-1 transition-colors hover:bg-gray-200">
<X className="h-4 w-4 text-gray-500" />
<button onClick={onClose} className="rounded-lg p-2 transition-all duration-200 hover:bg-white/80 hover:shadow-sm">
<X className="h-4 w-4 text-gray-500 hover:text-gray-700" />
</button>
</div>
@ -282,7 +282,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
{/* 리사이즈 핸들 */}
{resizable && !autoHeight && (
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gray-400" />
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gradient-to-br from-gray-400 to-gray-500 shadow-sm" />
</div>
)}
</div>

View File

@ -37,6 +37,7 @@ import {
RotateCw,
Folder,
FolderOpen,
Grid,
} from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
@ -1721,7 +1722,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
};
return (
<div className={cn("flex h-full flex-col", className)} style={{ ...style, minHeight: "680px" }}>
<div className={cn("flex h-full flex-col rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm", className)} style={{ ...style, minHeight: "680px" }}>
{/* 헤더 */}
<div className="p-6 pb-3">
<div className="flex items-center justify-between">
@ -1811,7 +1812,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<div className="flex h-full flex-col">
{visibleColumns.length > 0 ? (
<>
<Table>
<div className="rounded-lg border border-gray-200/60 bg-white shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow>
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
@ -1826,7 +1828,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.map((column: DataTableColumn) => (
<TableHead
key={column.id}
className="px-4 font-semibold"
className="px-4 font-semibold text-gray-700 bg-gradient-to-r from-gray-50 to-slate-50"
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
>
{column.label}
@ -1850,7 +1852,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</TableRow>
) : data.length > 0 ? (
data.map((row, rowIndex) => (
<TableRow key={rowIndex} className="hover:bg-muted/50">
<TableRow key={rowIndex} className="hover:bg-gradient-to-r hover:from-blue-50/50 hover:to-indigo-50/30 transition-all duration-200">
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
<TableCell className="w-12 px-4">
@ -1861,7 +1863,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</TableCell>
)}
{visibleColumns.map((column: DataTableColumn) => (
<TableCell key={column.id} className="px-4 font-mono text-sm">
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
{formatCellValue(row[column.columnName], column, row)}
</TableCell>
))}
@ -1884,10 +1886,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
)}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{component.pagination?.enabled && totalPages > 1 && (
<div className="bg-muted/20 mt-auto border-t">
<div className="bg-gradient-to-r from-gray-50 to-slate-50 mt-auto border-t border-gray-200/60">
<div className="flex items-center justify-between px-6 py-3">
{component.pagination.showPageInfo && (
<div className="text-muted-foreground text-sm">

View File

@ -42,6 +42,7 @@ import { enhancedFormService } from "@/lib/services/enhancedFormService";
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
import { useFormValidation } from "@/hooks/useFormValidation";
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
interface InteractiveScreenViewerProps {
component: ComponentData;
@ -771,11 +772,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const currentValue = getCurrentValue();
// 화면 ID 추출 (URL에서)
const screenId = typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
? parseInt(window.location.pathname.split('/screens/')[1])
: null;
console.log("📁 InteractiveScreenViewer - File 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
currentValue,
screenId,
appliedSettings: {
accept: config?.accept,
multiple: config?.multiple,
@ -1572,7 +1579,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
};
// 파일 첨부 컴포넌트 처리
if (component.type === "file") {
if (isFileComponent(component)) {
const fileComponent = component as FileComponent;
console.log("🎯 File 컴포넌트 렌더링:", {
@ -1719,17 +1726,19 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return (
<>
<div className="h-full w-full">
<div className="h-full w-full rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 p-4 shadow-sm transition-all duration-200 hover:shadow-md">
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block" style={labelStyle}>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
<div className="block mb-3" style={labelStyle}>
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold">
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "4px" }}>*</span>}
</div>
</div>
)}
{/* 실제 위젯 */}
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
<div className="flex-1 rounded-lg overflow-hidden">{renderInteractiveWidget(component)}</div>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}

View File

@ -13,6 +13,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -143,7 +144,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 동적 대화형 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 데이터 테이블 컴포넌트 처리
if (comp.type === "datatable") {
if (isDataTableComponent(comp)) {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
@ -157,12 +158,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
// 버튼 컴포넌트 처리
if (comp.type === "button") {
if (isButtonComponent(comp)) {
return renderButton(comp);
}
// 파일 컴포넌트 처리
if (comp.type === "file") {
if (isFileComponent(comp)) {
return renderFileComponent(comp as FileComponent);
}
@ -413,6 +414,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const { label, readonly } = comp;
const fieldName = comp.columnName || comp.id;
// 화면 ID 추출 (URL에서)
const screenId = screenInfo?.screenId ||
(typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
? parseInt(window.location.pathname.split('/screens/')[1])
: null);
return (
<div className="h-full w-full">
{/* 실제 FileUploadComponent 사용 */}
@ -433,12 +440,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
isInteractive={true}
isDesignMode={false}
formData={{
tableName: screenInfo?.tableName,
screenId, // 🎯 화면 ID 전달
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
autoLink: true, // 자동 연결 활성화
linkedTable: 'screen_files', // 연결 테이블
recordId: screenId, // 레코드 ID
columnName: fieldName, // 컬럼명 (중요!)
isVirtualFileColumn: true, // 가상 파일 컬럼
id: formData.id,
...formData
}}
onFormDataChange={(data) => {
console.log("📝 파일 업로드 완료:", data);
console.log("📝 실제 화면 파일 업로드 완료:", data);
if (onFormDataChange) {
Object.entries(data).forEach(([key, value]) => {
onFormDataChange(key, value);
@ -446,11 +459,57 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
}}
onUpdate={(updates) => {
console.log("🔄 파일 컴포넌트 업데이트:", updates);
// 파일 업로드 완료 시 formData 업데이트
console.log("🔄🔄🔄 실제 화면 파일 컴포넌트 업데이트:", {
componentId: comp.id,
hasUploadedFiles: !!updates.uploadedFiles,
filesCount: updates.uploadedFiles?.length || 0,
hasLastFileUpdate: !!updates.lastFileUpdate,
updates
});
// 파일 업로드/삭제 완료 시 formData 업데이터
if (updates.uploadedFiles && onFormDataChange) {
onFormDataChange(fieldName, updates.uploadedFiles);
}
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 (업로드/삭제 모두)
if (updates.uploadedFiles !== undefined && typeof window !== 'undefined') {
// 업로드인지 삭제인지 판단 (lastFileUpdate가 있으면 변경사항 있음)
const action = updates.lastFileUpdate ? 'update' : 'sync';
const eventDetail = {
componentId: comp.id,
files: updates.uploadedFiles,
fileCount: updates.uploadedFiles.length,
action: action,
timestamp: updates.lastFileUpdate || Date.now(),
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
};
console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
const event = new CustomEvent('globalFileStateChanged', {
detail: eventDetail
});
window.dispatchEvent(event);
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
setTimeout(() => {
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true }
}));
}, 100);
setTimeout(() => {
console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 2 }
}));
}, 500);
}
}}
/>
</div>
@ -473,19 +532,19 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return (
<>
<div className="absolute" style={componentStyle}>
<div className="h-full w-full">
<div className="h-full w-full rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 p-4 shadow-sm transition-all duration-200 hover:shadow-md">
{/* 라벨 표시 - 컴포넌트 내부에서 라벨을 처리하므로 외부에서는 표시하지 않음 */}
{!hideLabel && component.label && component.style?.labelDisplay === false && (
<div className="mb-1">
<label className="text-sm font-medium text-gray-700">
<div className="mb-3">
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold text-gray-700">
{component.label}
{(component as WidgetComponent).required && <span className="ml-1 text-red-500">*</span>}
</label>
{(component as WidgetComponent).required && <span className="ml-1 text-orange-500">*</span>}
</div>
</div>
)}
{/* 위젯 렌더링 */}
<div className="flex-1">{renderInteractiveWidget(component)}</div>
<div className="flex-1 rounded-lg overflow-hidden">{renderInteractiveWidget(component)}</div>
</div>
</div>

View File

@ -1,7 +1,8 @@
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -126,6 +127,17 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
className: `w-full h-full ${borderClass}`,
};
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
if (isFileComponent(widget)) {
console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
componentId: widget.id,
widgetType: widgetType,
isFileComponent: true
});
return <div className="text-xs text-gray-500 p-2"> ( )</div>;
}
// 동적 웹타입 렌더링 사용
if (widgetType) {
try {
@ -209,6 +221,89 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}) => {
const { user } = useAuth();
const { type, id, position, size, style = {} } = component;
const [fileUpdateTrigger, setFileUpdateTrigger] = useState(0);
// 전역 파일 상태 변경 감지 (해당 컴포넌트만)
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
eventComponentId: event.detail.componentId,
currentComponentId: component.id,
isMatch: event.detail.componentId === component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action,
delayed: event.detail.delayed || false,
attempt: event.detail.attempt || 1,
eventDetail: event.detail
});
if (event.detail.componentId === component.id) {
console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
componentId: component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action,
oldTrigger: fileUpdateTrigger,
delayed: event.detail.delayed || false,
attempt: event.detail.attempt || 1
});
setFileUpdateTrigger(prev => {
const newTrigger = prev + 1;
console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
old: prev,
new: newTrigger,
componentId: component.id,
attempt: event.detail.attempt || 1
});
return newTrigger;
});
} else {
console.log("❌ 컴포넌트 ID 불일치:", {
eventComponentId: event.detail.componentId,
currentComponentId: component.id
});
}
};
// 강제 업데이트 함수 등록
const forceUpdate = (componentId: string, files: any[]) => {
console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
targetComponentId: componentId,
currentComponentId: component.id,
isMatch: componentId === component.id,
filesCount: files.length
});
if (componentId === component.id) {
console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
componentId: component.id,
filesCount: files.length,
oldTrigger: fileUpdateTrigger
});
setFileUpdateTrigger(prev => {
const newTrigger = prev + 1;
console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
old: prev,
new: newTrigger,
componentId: component.id
});
return newTrigger;
});
}
};
if (typeof window !== 'undefined') {
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
// 전역 강제 업데이트 함수 등록
if (!(window as any).forceRealtimePreviewUpdate) {
(window as any).forceRealtimePreviewUpdate = forceUpdate;
}
return () => {
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
};
}
}, [component.id, fileUpdateTrigger]);
// 컴포넌트 스타일 계산
const componentStyle = {
@ -299,8 +394,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
</div>
)}
{/* 위젯 타입 - 동적 렌더링 */}
{type === "widget" && (
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
{type === "widget" && !isFileComponent(component) && (
<div className="flex h-full flex-col">
<div className="pointer-events-none flex-1">
<WidgetRenderer component={component} />
@ -308,8 +403,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
</div>
)}
{/* 파일 타입 */}
{type === "file" && (() => {
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
{isFileComponent(component) && (() => {
const fileComponent = component as any;
const uploadedFiles = fileComponent.uploadedFiles || [];
@ -327,11 +422,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
currentFilesCount: currentFiles.length,
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
componentType: component.type,
fileUpdateTrigger: fileUpdateTrigger,
timestamp: new Date().toISOString()
});
return (
<div className="flex h-full flex-col">
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
{currentFiles.length > 0 ? (
<div className="h-full overflow-y-auto">

View File

@ -734,6 +734,100 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
initComponents();
}, []);
// 화면 선택 시 파일 복원
useEffect(() => {
if (selectedScreen?.screenId) {
restoreScreenFiles();
}
}, [selectedScreen?.screenId]);
// 화면의 모든 파일 컴포넌트 파일 복원
const restoreScreenFiles = useCallback(async () => {
if (!selectedScreen?.screenId) return;
try {
console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
// 해당 화면의 모든 파일 조회
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
if (response.success && response.componentFiles) {
console.log("📁 복원할 파일 데이터:", response.componentFiles);
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
if (Array.isArray(serverFiles) && serverFiles.length > 0) {
// 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const currentGlobalFiles = globalFileState[componentId] || [];
let currentLocalStorageFiles: any[] = [];
if (typeof window !== 'undefined') {
try {
const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`);
if (storedFiles) {
currentLocalStorageFiles = JSON.parse(storedFiles);
}
} catch (e) {
console.warn("localStorage 파일 파싱 실패:", e);
}
}
// 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터
let finalFiles = serverFiles;
if (currentGlobalFiles.length > 0) {
finalFiles = currentGlobalFiles;
console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개");
} else if (currentLocalStorageFiles.length > 0) {
finalFiles = currentLocalStorageFiles;
console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개");
} else {
console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
}
// 전역 상태에 파일 저장
globalFileState[componentId] = finalFiles;
if (typeof window !== 'undefined') {
(window as any).globalFileState = globalFileState;
}
// localStorage에도 백업
if (typeof window !== 'undefined') {
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles));
}
}
});
// 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선)
setLayout(prevLayout => {
const updatedComponents = prevLayout.components.map(comp => {
// 🎯 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const finalFiles = globalFileState[comp.id] || [];
if (finalFiles.length > 0) {
return {
...comp,
uploadedFiles: finalFiles,
lastFileUpdate: Date.now()
};
}
return comp;
});
return {
...prevLayout,
components: updatedComponents
};
});
console.log("✅ 화면 파일 복원 완료");
}
} catch (error) {
console.error("❌ 화면 파일 복원 오류:", error);
}
}, [selectedScreen?.screenId]);
// 전역 파일 상태 변경 이벤트 리스너
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
@ -3302,7 +3396,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
return (
<div className="flex h-screen w-full flex-col bg-gray-100">
<div className="flex h-screen w-full flex-col bg-gradient-to-br from-gray-50 to-slate-100">
{/* 상단 툴바 */}
<DesignerToolbar
screenName={selectedScreen?.screenName}
@ -3322,7 +3416,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
/>
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
<div className="relative flex-1 overflow-auto bg-gray-100 px-2 py-6">
<div className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6">
{/* 해상도 정보 표시 - 적당한 여백 */}
<div className="mb-4 flex items-center justify-center">
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">

View File

@ -64,7 +64,7 @@ export default function ColumnComponent({
className={cn(
"flex-1 rounded border border-gray-200 p-2",
isSelected && "border-blue-500 bg-blue-50",
isMoving && "cursor-move shadow-lg",
isMoving && "cursor-move",
className,
)}
style={style}

View File

@ -64,7 +64,7 @@ export default function ContainerComponent({
className={cn(
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
isSelected && "border-blue-500 bg-blue-50",
isMoving && "cursor-move shadow-lg",
isMoving && "cursor-move",
className,
)}
style={style}

View File

@ -64,7 +64,7 @@ export default function RowComponent({
className={cn(
"flex gap-4 rounded border border-gray-200 p-2",
isSelected && "border-blue-500 bg-blue-50",
isMoving && "cursor-move shadow-lg",
isMoving && "cursor-move",
className,
)}
style={style}

View File

@ -125,126 +125,145 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
};
return (
<Card className={className}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center">
<Package className="mr-2 h-5 w-5" />
({componentsByCategory.all.length})
<div className={`h-full bg-gradient-to-br from-slate-50 to-purple-50/30 border-r border-gray-200/60 shadow-sm ${className}`}>
<div className="p-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-1"></h2>
<p className="text-sm text-gray-500">{componentsByCategory.all.length} </p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
title="컴포넌트 새로고침"
className="bg-white/60 border-gray-200/60 hover:bg-white hover:border-gray-300"
>
<RotateCcw className="h-4 w-4" />
</Button>
</CardTitle>
</div>
{/* 검색창 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
<div className="relative mb-6">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="컴포넌트 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
/>
</div>
</CardHeader>
</div>
<CardContent>
<div className="px-6">
<Tabs
value={selectedCategory}
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
>
{/* 카테고리 탭 (input 카테고리 제외) */}
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
<TabsTrigger value="all" className="flex items-center">
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5 bg-white/60 backdrop-blur-sm border-0 p-1">
<TabsTrigger value="all" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Package className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="display" className="flex items-center">
<TabsTrigger value="display" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Palette className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="action" className="flex items-center">
<TabsTrigger value="action" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Zap className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="layout" className="flex items-center">
<TabsTrigger value="layout" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Layers className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="utility" className="flex items-center">
<TabsTrigger value="utility" className="flex items-center text-xs data-[state=active]:bg-purple-600 data-[state=active]:text-white">
<Package className="mr-1 h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 컴포넌트 목록 */}
<div className="mt-4">
<TabsContent value={selectedCategory} className="space-y-2">
<div className="mt-6">
<TabsContent value={selectedCategory} className="space-y-3">
{filteredComponents.length > 0 ? (
<div className="grid max-h-96 grid-cols-1 gap-2 overflow-y-auto">
<div className="grid max-h-96 grid-cols-1 gap-3 overflow-y-auto pr-2">
{filteredComponents.map((component) => (
<div
key={component.id}
draggable
onDragStart={(e) => handleDragStart(e, component)}
className="hover:bg-accent flex cursor-grab items-center rounded-lg border p-3 transition-colors active:cursor-grabbing"
onDragStart={(e) => {
handleDragStart(e, component);
// 드래그 시작 시 시각적 피드백
e.currentTarget.style.opacity = '0.5';
e.currentTarget.style.transform = 'rotate(-3deg) scale(0.95)';
}}
onDragEnd={(e) => {
// 드래그 종료 시 원래 상태로 복원
e.currentTarget.style.opacity = '1';
e.currentTarget.style.transform = 'none';
}}
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 hover:scale-[1.02] hover:border-purple-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
title={component.description}
>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center justify-between">
<h4 className="truncate text-sm font-medium">{component.name}</h4>
<div className="flex items-center space-x-1">
{/* 카테고리 뱃지 */}
<Badge variant="secondary" className="text-xs">
{getCategoryIcon(component.category)}
<span className="ml-1">{component.category}</span>
</Badge>
{/* 새 컴포넌트 뱃지 */}
<Badge variant="default" className="bg-green-500 text-xs">
<div className="flex items-start space-x-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
{getCategoryIcon(component.category)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{component.name}</h4>
<Badge variant="default" className="bg-gradient-to-r from-emerald-500 to-emerald-600 text-white text-xs border-0 ml-2 px-2 py-1 rounded-full font-medium shadow-sm">
</Badge>
</div>
</div>
<p className="text-muted-foreground truncate text-xs">{component.description}</p>
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{component.description}</p>
{/* 웹타입 및 크기 정보 */}
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
<span>: {component.webType}</span>
<span>
{component.defaultSize.width}×{component.defaultSize.height}
</span>
</div>
{/* 태그 */}
{component.tags && component.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{component.tags.slice(0, 3).map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{component.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{component.tags.length - 3}
</Badge>
)}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2 text-xs text-gray-400">
<span className="bg-gradient-to-r from-gray-100 to-gray-200 px-3 py-1 rounded-full font-medium text-gray-600">
{component.defaultSize.width}×{component.defaultSize.height}
</span>
</div>
<span className="text-xs font-medium text-purple-600 capitalize bg-gradient-to-r from-purple-50 to-indigo-50 px-3 py-1 rounded-full border border-purple-200/50">
{component.category}
</span>
</div>
)}
{/* 태그 */}
{component.tags && component.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{component.tags.slice(0, 2).map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs bg-gradient-to-r from-gray-50 to-gray-100 text-gray-600 border-gray-200/50 rounded-full px-2 py-1">
{tag}
</Badge>
))}
{component.tags.length > 2 && (
<Badge variant="outline" className="text-xs bg-gradient-to-r from-gray-50 to-gray-100 text-gray-600 border-gray-200/50 rounded-full px-2 py-1">
+{component.tags.length - 2}
</Badge>
)}
</div>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground py-8 text-center">
<Package className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="text-sm">
{searchQuery
? `"${searchQuery}"에 대한 검색 결과가 없습니다.`
: "이 카테고리에 컴포넌트가 없습니다."}
</p>
<div className="py-12 text-center text-gray-500">
<div className="p-8">
<Package className="mx-auto mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-600">
{searchQuery
? `"${searchQuery}"에 대한 컴포넌트를 찾을 수 없습니다`
: "이 카테고리에 컴포넌트가 없습니다"}
</p>
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
</div>
)}
</TabsContent>
@ -252,31 +271,40 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
</Tabs>
{/* 통계 정보 */}
<div className="mt-4 border-t pt-3">
<div className="mt-6 rounded-xl bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-100/60 p-4">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-bold text-green-600">{filteredComponents.length}</div>
<div className="text-muted-foreground text-xs"> </div>
<div className="text-lg font-bold text-emerald-600">{filteredComponents.length}</div>
<div className="text-xs text-gray-500"></div>
</div>
<div>
<div className="text-lg font-bold text-blue-600">{allComponents.length}</div>
<div className="text-muted-foreground text-xs"> </div>
<div className="text-lg font-bold text-purple-600">{allComponents.length}</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
</div>
{/* 개발 정보 (개발 모드에서만) */}
{process.env.NODE_ENV === "development" && (
<div className="mt-4 border-t pt-3">
<div className="text-muted-foreground space-y-1 text-xs">
<div>🔧 </div>
<div> Hot Reload </div>
<div>🛡 </div>
<div className="mt-4 rounded-xl bg-gradient-to-r from-gray-50 to-slate-50 border border-gray-100/60 p-4">
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-center space-x-2">
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
<span> </span>
</div>
<div className="flex items-center space-x-2">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
<span>Hot Reload </span>
</div>
<div className="flex items-center space-x-2">
<span className="w-2 h-2 bg-purple-500 rounded-full"></span>
<span> </span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -15,6 +15,7 @@ import {
} from "@/types/screen";
// 레거시 ButtonConfigPanel 제거됨
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
// 새로운 컴포넌트 설정 패널들 import
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
@ -908,7 +909,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
if (selectedComponent.type === "file" || (selectedComponent.type === "widget" && selectedComponent.widgetType === "file")) {
if (isFileComponent(selectedComponent)) {
const fileComponent = selectedComponent as FileComponent;
return (

View File

@ -10,7 +10,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { FileComponent, TableInfo } from "@/types/screen";
import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FileInfo } from "@/lib/registry/components/file-upload/types";
import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types";
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
import { formatFileSize } from "@/lib/utils";
import { toast } from "sonner";
@ -28,6 +28,13 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
currentTable,
currentTableName,
}) => {
console.log("🎨🎨🎨 FileComponentConfigPanel 렌더링:", {
componentId: component?.id,
componentType: component?.type,
hasOnUpdateProperty: !!onUpdateProperty,
currentTable,
currentTableName
});
// fileConfig가 없는 경우 초기화
React.useEffect(() => {
if (!component.fileConfig) {
@ -112,13 +119,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
const componentFiles = component.uploadedFiles || [];
const globalFiles = getGlobalFileState()[component.id] || [];
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일)
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일 + FileUploadComponent 백업)
const backupKey = `fileComponent_${component.id}_files`;
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
const fileUploadBackupKey = `fileUpload_${component.id}`; // 🎯 실제 화면과 동기화
const backupFiles = localStorage.getItem(backupKey);
const tempBackupFiles = localStorage.getItem(tempBackupKey);
const fileUploadBackupFiles = localStorage.getItem(fileUploadBackupKey); // 🎯 실제 화면 백업
let parsedBackupFiles: FileInfo[] = [];
let parsedTempFiles: FileInfo[] = [];
let parsedFileUploadFiles: FileInfo[] = []; // 🎯 실제 화면 파일
if (backupFiles) {
try {
@ -136,8 +148,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
}
}
// 우선순위: 전역 상태 > localStorage > 임시 파일 > 컴포넌트 속성
// 🎯 실제 화면 FileUploadComponent 백업 파싱
if (fileUploadBackupFiles) {
try {
parsedFileUploadFiles = JSON.parse(fileUploadBackupFiles);
} catch (error) {
console.error("FileUploadComponent 백업 파일 파싱 실패:", error);
}
}
// 🎯 우선순위: 전역 상태 > FileUploadComponent 백업 > localStorage > 임시 파일 > 컴포넌트 속성
const finalFiles = globalFiles.length > 0 ? globalFiles :
parsedFileUploadFiles.length > 0 ? parsedFileUploadFiles : // 🎯 실제 화면 우선
parsedBackupFiles.length > 0 ? parsedBackupFiles :
parsedTempFiles.length > 0 ? parsedTempFiles :
componentFiles;
@ -148,8 +170,12 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
globalFiles: globalFiles.length,
backupFiles: parsedBackupFiles.length,
tempFiles: parsedTempFiles.length,
fileUploadFiles: parsedFileUploadFiles.length, // 🎯 실제 화면 파일 수
finalFiles: finalFiles.length,
source: globalFiles.length > 0 ? 'global' : parsedBackupFiles.length > 0 ? 'localStorage' : parsedTempFiles.length > 0 ? 'temp' : 'component'
source: globalFiles.length > 0 ? 'global' :
parsedFileUploadFiles.length > 0 ? 'fileUploadComponent' : // 🎯 실제 화면 소스
parsedBackupFiles.length > 0 ? 'localStorage' :
parsedTempFiles.length > 0 ? 'temp' : 'component'
});
return finalFiles;
@ -190,7 +216,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
// 파일 업로드 처리
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
console.log("🚀🚀🚀 FileComponentConfigPanel 파일 업로드 시작:", {
filesCount: files?.length || 0,
componentId: component?.id,
componentType: component?.type,
hasOnUpdateProperty: !!onUpdateProperty
});
if (!files || files.length === 0) {
console.log("❌ 파일이 없음");
return;
}
const fileArray = Array.from(files);
const validFiles: File[] = [];
@ -291,23 +327,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
setUploading(true);
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
// 그리드와 연동되는 targetObjid 생성 (화면 복원 시스템과 통일)
const tableName = 'screen_files';
const screenId = (window as any).__CURRENT_SCREEN_ID__ || 'unknown'; // 현재 화면 ID
// 🎯 여러 방법으로 screenId 확인
let screenId = (window as any).__CURRENT_SCREEN_ID__;
// 1차: 전역 변수에서 가져오기
if (!screenId) {
// 2차: URL에서 추출 시도
if (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')) {
const pathScreenId = window.location.pathname.split('/screens/')[1];
if (pathScreenId && !isNaN(parseInt(pathScreenId))) {
screenId = parseInt(pathScreenId);
}
}
}
// 3차: 기본값 설정
if (!screenId) {
screenId = 40; // 기본 화면 ID (디자인 모드용)
console.warn("⚠️ screenId를 찾을 수 없어 기본값(40) 사용");
}
const componentId = component.id;
const fieldName = component.columnName || component.id || 'file_attachment';
const targetObjid = `${tableName}:${screenId}:${componentId}:${fieldName}`;
console.log("📋 파일 업로드 기본 정보:", {
screenId,
screenIdSource: (window as any).__CURRENT_SCREEN_ID__ ? 'global' : 'url_or_default',
componentId,
fieldName,
docType: localInputs.docType,
docTypeName: localInputs.docTypeName,
currentPath: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
});
const response = await uploadFiles({
files: filesToUpload,
tableName: tableName,
fieldName: fieldName,
recordId: `${screenId}:${componentId}`, // 화면ID:컴포넌트ID 형태
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
autoLink: true, // 자동 연결 활성화
linkedTable: 'screen_files', // 연결 테이블
recordId: screenId, // 레코드 ID
columnName: fieldName, // 컬럼명
isVirtualFileColumn: true, // 가상 파일 컬럼
docType: localInputs.docType,
docTypeName: localInputs.docTypeName,
targetObjid: targetObjid, // 그리드 연동을 위한 targetObjid
columnName: fieldName,
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
});
console.log("📤 파일 업로드 응답:", response);
@ -358,6 +420,65 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
const backupKey = `fileComponent_${component.id}_files`;
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
// 전역 파일 상태 변경 이벤트 발생 (RealtimePreview 업데이트용)
if (typeof window !== 'undefined') {
const eventDetail = {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
action: 'upload',
timestamp: Date.now(),
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
};
console.log("🚀🚀🚀 FileComponentConfigPanel 이벤트 발생:", eventDetail);
console.log("🔍 현재 컴포넌트 ID:", component.id);
console.log("🔍 업로드된 파일 수:", updatedFiles.length);
console.log("🔍 파일 목록:", updatedFiles.map(f => f.name));
const event = new CustomEvent('globalFileStateChanged', {
detail: eventDetail
});
// 이벤트 리스너가 있는지 확인
const listenerCount = window.getEventListeners ?
window.getEventListeners(window)?.globalFileStateChanged?.length || 0 :
'unknown';
console.log("🔍 globalFileStateChanged 리스너 수:", listenerCount);
window.dispatchEvent(event);
console.log("✅✅✅ globalFileStateChanged 이벤트 발생 완료");
// 강제로 모든 RealtimePreview 컴포넌트에게 알림 (여러 번)
setTimeout(() => {
console.log("🔄 추가 이벤트 발생 (지연 100ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true }
}));
}, 100);
setTimeout(() => {
console.log("🔄 추가 이벤트 발생 (지연 300ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 2 }
}));
}, 300);
setTimeout(() => {
console.log("🔄 추가 이벤트 발생 (지연 500ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 3 }
}));
}, 500);
// 직접 전역 상태 강제 업데이트
console.log("🔄 전역 상태 강제 업데이트 시도");
if ((window as any).forceRealtimePreviewUpdate) {
(window as any).forceRealtimePreviewUpdate(component.id, updatedFiles);
}
}
console.log("🔄 FileComponentConfigPanel 자동 저장:", {
componentId: component.id,
uploadedFiles: updatedFiles.length,
@ -369,6 +490,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== 'undefined') {
const tableName = component.tableName || currentTableName || 'unknown';
const columnName = component.columnName || component.id;
const recordId = component.id; // 임시로 컴포넌트 ID 사용
const targetObjid = component.id;
const refreshEvent = new CustomEvent('refreshFileStatus', {
detail: {
tableName: tableName,
@ -399,10 +525,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
} else {
throw new Error(response.message || '파일 업로드에 실패했습니다.');
}
} catch (error) {
console.error('❌ 파일 업로드 오류:', error);
} catch (error: any) {
console.error('❌ 파일 업로드 오류:', {
error,
errorMessage: error?.message,
errorResponse: error?.response?.data,
errorStatus: error?.response?.status,
componentId: component?.id,
screenId,
fieldName
});
toast.dismiss();
toast.error('파일 업로드에 실패했습니다.');
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`);
} finally {
console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
setUploading(false);
@ -413,7 +547,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
await downloadFile({
fileId: file.objid || file.id,
fileId: file.objid || file.id || '',
serverFilename: file.savedFileName,
originalName: file.realFileName || file.name || 'download',
});
@ -426,8 +560,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
// 파일 삭제 처리
const handleFileDelete = useCallback(async (fileId: string) => {
console.log("🗑️🗑️🗑️ FileComponentConfigPanel 파일 삭제 시작:", {
fileId,
componentId: component?.id,
currentFilesCount: uploadedFiles.length,
hasOnUpdateProperty: !!onUpdateProperty
});
try {
await deleteFile(fileId);
console.log("📡 deleteFile API 호출 시작...");
await deleteFile(fileId, 'temp_record');
console.log("✅ deleteFile API 호출 성공");
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
setUploadedFiles(updatedFiles);
@ -455,8 +598,42 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
timestamp: timestamp
});
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== 'undefined') {
// 🎯 RealtimePreview 동기화를 위한 전역 이벤트 발생
if (typeof window !== 'undefined') {
const eventDetail = {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
action: 'delete',
timestamp: timestamp,
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
};
console.log("🚀🚀🚀 FileComponentConfigPanel 삭제 이벤트 발생:", eventDetail);
const event = new CustomEvent('globalFileStateChanged', {
detail: eventDetail
});
window.dispatchEvent(event);
console.log("✅✅✅ globalFileStateChanged 삭제 이벤트 발생 완료");
// 추가 지연 이벤트들
setTimeout(() => {
console.log("🔄 추가 삭제 이벤트 발생 (지연 100ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true }
}));
}, 100);
setTimeout(() => {
console.log("🔄 추가 삭제 이벤트 발생 (지연 300ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 2 }
}));
}, 300);
// 그리드 파일 상태 새로고침 이벤트도 유지
const tableName = currentTableName || 'screen_files';
const recordId = component.id;
const columnName = component.columnName || component.id || 'file_attachment';
@ -539,12 +716,22 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
e.preventDefault();
setDragOver(false);
const files = e.dataTransfer.files;
console.log("📂 드래그앤드롭 이벤트:", {
filesCount: files.length,
files: files.length > 0 ? Array.from(files).map(f => f.name) : [],
componentId: component?.id
});
if (files.length > 0) {
handleFileUpload(files);
}
}, [handleFileUpload]);
}, [handleFileUpload, component?.id]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
console.log("📁 파일 선택 이벤트:", {
filesCount: e.target.files?.length || 0,
files: e.target.files ? Array.from(e.target.files).map(f => f.name) : []
});
const files = e.target.files;
if (files && files.length > 0) {
handleFileUpload(files);
@ -649,20 +836,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
// 전역 파일 상태 변경 감지 (화면 복원 포함)
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
const { componentId, files, fileCount, isRestore } = event.detail;
const { componentId, files, fileCount, isRestore, source } = event.detail;
if (componentId === component.id) {
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
componentId,
fileCount,
isRestore: !!isRestore,
source: source || 'unknown',
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
if (files && Array.isArray(files)) {
setUploadedFiles(files);
if (isRestore) {
// 🎯 실제 화면에서 온 이벤트이거나 화면 복원인 경우 컴포넌트 속성도 업데이트
if (isRestore || source === 'realScreen') {
console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 적용:", {
componentId,
fileCount: files.length,
source: source || 'restore'
});
onUpdateProperty(component.id, "uploadedFiles", files);
onUpdateProperty(component.id, "lastFileUpdate", Date.now());
// localStorage 백업도 업데이트
try {
const backupKey = `fileComponent_${component.id}_files`;
localStorage.setItem(backupKey, JSON.stringify(files));
console.log("💾 실제 화면 동기화 후 localStorage 백업 업데이트:", {
componentId: component.id,
fileCount: files.length
});
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트
setGlobalFileState(prev => ({
...prev,
[component.id]: files
}));
} else if (isRestore) {
console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
componentId,
restoredFileCount: files.length
@ -679,7 +895,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
};
}
}, [component.id]);
}, [component.id, onUpdateProperty]);
// 미리 정의된 문서 타입들
const docTypeOptions = [
@ -875,18 +1091,33 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
{/* 파일 업로드 영역 */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-900"> </h4>
<Card>
<CardContent className="p-4">
<Card className="border-gray-200/60 shadow-sm">
<CardContent className="p-6">
<div
className={`
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-300
${dragOver ? 'border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm' : 'border-gray-300/60'}
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !uploading && document.getElementById('file-input-config')?.click()}
onClick={() => {
console.log("🖱️ 파일 업로드 영역 클릭:", {
uploading,
inputElement: document.getElementById('file-input-config'),
componentId: component?.id
});
if (!uploading) {
const input = document.getElementById('file-input-config');
if (input) {
console.log("✅ 파일 input 클릭 실행");
input.click();
} else {
console.log("❌ 파일 input 요소를 찾을 수 없음");
}
}
}}
>
<input
id="file-input-config"
@ -959,7 +1190,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDelete(file.objid || file.id)}
onClick={() => handleFileDelete(file.objid || file.id || '')}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
title="삭제"
>

View File

@ -147,7 +147,7 @@ export default function LayoutsPanel({
};
return (
<div className={`layouts-panel h-full ${className || ""}`}>
<div className={`layouts-panel h-full bg-gradient-to-br from-slate-50 to-indigo-50/30 border-r border-gray-200/60 shadow-sm ${className || ""}`}>
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b p-4">

View File

@ -487,16 +487,22 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
});
return (
<div className="flex h-full flex-col space-y-4 p-4">
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50/30 p-6 border-r border-gray-200/60 shadow-sm">
{/* 헤더 */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-1">릿</h2>
<p className="text-sm text-gray-500"> </p>
</div>
{/* 검색 */}
<div className="space-y-3">
<div className="space-y-4">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="템플릿 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
/>
</div>
@ -508,7 +514,13 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
variant={selectedCategory === category.id ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category.id)}
className="flex items-center space-x-1"
className={`
flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all
${selectedCategory === category.id
? 'bg-blue-600 text-white shadow-sm hover:bg-blue-700'
: 'bg-white/60 text-gray-600 border-gray-200/60 hover:bg-white hover:text-gray-900 hover:border-gray-300'
}
`}
>
{category.icon}
<span>{category.name}</span>
@ -517,23 +529,21 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
</div>
</div>
<Separator />
{/* 새로고침 버튼 */}
{error && (
<div className="flex items-center justify-between rounded-lg bg-yellow-50 p-3 text-yellow-800">
<div className="flex items-center justify-between rounded-xl bg-amber-50/80 border border-amber-200/60 p-3 text-amber-800 mb-4">
<div className="flex items-center space-x-2">
<Info className="h-4 w-4" />
<span className="text-sm">릿 , 릿 </span>
</div>
<Button size="sm" variant="outline" onClick={() => refetch()}>
<Button size="sm" variant="outline" onClick={() => refetch()} className="border-amber-300 text-amber-700 hover:bg-amber-100">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
)}
{/* 템플릿 목록 */}
<div className="flex-1 space-y-2 overflow-y-auto">
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<LoadingSpinner />
@ -541,9 +551,10 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
</div>
) : filteredTemplates.length === 0 ? (
<div className="flex h-32 items-center justify-center text-center text-gray-500">
<div>
<FileText className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> </p>
<div className="p-8">
<FileText className="mx-auto mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-600">릿 </p>
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
</div>
) : (
@ -551,27 +562,40 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
<div
key={template.id}
draggable
onDragStart={(e) => onDragStart(e, template)}
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
onDragStart={(e) => {
onDragStart(e, template);
// 드래그 시작 시 시각적 피드백
e.currentTarget.style.opacity = '0.6';
e.currentTarget.style.transform = 'rotate(2deg) scale(0.98)';
}}
onDragEnd={(e) => {
// 드래그 종료 시 원래 상태로 복원
e.currentTarget.style.opacity = '1';
e.currentTarget.style.transform = 'none';
}}
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-5 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-blue-500/15 hover:scale-[1.02] hover:border-blue-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
>
<div className="flex items-start space-x-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
<div className="flex items-start space-x-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
{template.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-2">
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
<Badge variant="secondary" className="text-xs">
{template.components.length}
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{template.name}</h4>
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 border-0 ml-2 px-2 py-1 rounded-full font-medium">
{template.components.length}
</Badge>
</div>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
<span>
{template.defaultSize.width}×{template.defaultSize.height}
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{template.description}</p>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 text-xs text-gray-400">
<span className="bg-gradient-to-r from-gray-100 to-gray-200 px-3 py-1 rounded-full font-medium text-gray-600">
{template.defaultSize.width}×{template.defaultSize.height}
</span>
</div>
<span className="text-xs font-medium text-blue-600 capitalize bg-gradient-to-r from-blue-50 to-indigo-50 px-3 py-1 rounded-full border border-blue-200/50">
{template.category}
</span>
<span></span>
<span className="capitalize">{template.category}</span>
</div>
</div>
</div>
@ -581,12 +605,14 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
</div>
{/* 도움말 */}
<div className="rounded-lg bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
<div className="text-xs text-blue-700">
<p className="mb-1 font-medium"> </p>
<p>릿 .</p>
<div className="rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-100/60 p-4 mt-6">
<div className="flex items-start space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
<Info className="h-4 w-4" />
</div>
<div className="text-xs text-blue-800">
<p className="font-semibold mb-1"> </p>
<p className="text-blue-600 leading-relaxed">릿 .</p>
</div>
</div>
</div>

View File

@ -504,6 +504,26 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
[component.id]: updatedFiles
}));
// RealtimePreview 동기화를 위한 추가 이벤트 발생
if (typeof window !== 'undefined') {
const eventDetail = {
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
action: 'upload',
timestamp: Date.now()
};
console.log("🚀 FileUpload 위젯 이벤트 발생:", eventDetail);
const event = new CustomEvent('globalFileStateChanged', {
detail: eventDetail
});
window.dispatchEvent(event);
console.log("✅ FileUpload globalFileStateChanged 이벤트 발생 완료");
}
// 컴포넌트 업데이트 (옵셔널)
if (onUpdateComponent) {
onUpdateComponent({
@ -583,6 +603,42 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
[component.id]: filteredFiles
}));
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생
if (typeof window !== 'undefined') {
const eventDetail = {
componentId: component.id,
files: filteredFiles,
fileCount: filteredFiles.length,
action: 'delete',
timestamp: Date.now(),
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
};
console.log("🚀🚀🚀 FileUpload 위젯 삭제 이벤트 발생:", eventDetail);
const event = new CustomEvent('globalFileStateChanged', {
detail: eventDetail
});
window.dispatchEvent(event);
console.log("✅✅✅ FileUpload 위젯 → 화면설계 모드 동기화 이벤트 발생 완료");
// 추가 지연 이벤트들
setTimeout(() => {
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true }
}));
}, 100);
setTimeout(() => {
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)");
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
detail: { ...eventDetail, delayed: true, attempt: 2 }
}));
}, 300);
}
onUpdateComponent({
uploadedFiles: filteredFiles,
});
@ -635,8 +691,8 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
<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"
className={`rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300 ${
isDragOver ? "border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm" : "border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -648,7 +704,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
</p>
<p className="mb-4 text-sm text-gray-500"> </p>
<Button variant="outline" onClick={handleFileInputClick} className="mb-4">
<Button variant="outline" onClick={handleFileInputClick} className="mb-4 rounded-lg border-gray-300/60 hover:border-blue-400/60 hover:bg-blue-50/50 transition-all duration-200">
<Upload className="mr-2 h-4 w-4" />
{fileConfig.uploadButtonText || "파일 선택"}
</Button>

View File

@ -134,7 +134,7 @@ export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value,
<div className="h-full w-full space-y-2">
{/* 파일 업로드 영역 */}
<div
className="border-muted-foreground/25 hover:border-muted-foreground/50 cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors"
className="border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 cursor-pointer rounded-xl border-2 border-dashed p-6 text-center transition-all duration-300 hover:shadow-sm"
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDrop={handleDrop}

View File

@ -120,23 +120,39 @@ class BatchManagementAPIClass {
apiUrl: string,
apiKey: string,
endpoint: string,
method: 'GET' = 'GET'
method: 'GET' = 'GET',
paramInfo?: {
paramType: 'url' | 'query';
paramName: string;
paramValue: string;
paramSource: 'static' | 'dynamic';
}
): Promise<{
fields: string[];
samples: any[];
totalCount: number;
}> {
try {
const response = await apiClient.post<BatchApiResponse<{
fields: string[];
samples: any[];
totalCount: number;
}>>(`${this.BASE_PATH}/rest-api/preview`, {
const requestData: any = {
apiUrl,
apiKey,
endpoint,
method
});
};
// 파라미터 정보가 있으면 추가
if (paramInfo) {
requestData.paramType = paramInfo.paramType;
requestData.paramName = paramInfo.paramName;
requestData.paramValue = paramInfo.paramValue;
requestData.paramSource = paramInfo.paramSource;
}
const response = await apiClient.post<BatchApiResponse<{
fields: string[];
samples: any[];
totalCount: number;
}>>(`${this.BASE_PATH}/rest-api/preview`, requestData);
if (!response.data.success) {
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");

View File

@ -16,6 +16,7 @@ export interface FileUploadResponse {
success: boolean;
message: string;
files: FileInfo[];
data?: FileInfo[];
}
export interface FileDownloadParams {
@ -134,6 +135,28 @@ export const getFileInfo = async (fileId: string, serverFilename: string) => {
return response.data;
};
/**
* 릿
*/
export const getComponentFiles = async (params: {
screenId: number;
componentId: string;
tableName?: string;
recordId?: string;
columnName?: string;
}): Promise<{
success: boolean;
templateFiles: FileInfo[];
dataFiles: FileInfo[];
totalFiles: FileInfo[];
}> => {
const response = await apiClient.get('/files/component-files', {
params,
});
return response.data;
};
/**
* JSON
* InteractiveScreenViewer에서

View File

@ -0,0 +1,183 @@
import { FileInfo } from "./file";
export interface GlobalFileInfo extends FileInfo {
uploadPage: string;
uploadTime: string;
componentId: string;
screenId?: number;
accessible: boolean;
}
/**
*
*
*/
export class GlobalFileManager {
private static readonly STORAGE_KEY = 'globalFileRegistry';
private static readonly SESSION_STORAGE_KEY = 'globalFileRegistrySession';
/**
*
*/
static getRegistry(): Record<string, GlobalFileInfo> {
if (typeof window === 'undefined') return {};
// 1. 메모리에서 먼저 확인
if ((window as any).globalFileRegistry) {
return (window as any).globalFileRegistry;
}
// 2. sessionStorage에서 확인 (세션 동안 유지)
const sessionData = sessionStorage.getItem(this.SESSION_STORAGE_KEY);
if (sessionData) {
try {
const parsedData = JSON.parse(sessionData);
(window as any).globalFileRegistry = parsedData;
return parsedData;
} catch (error) {
console.warn('세션 파일 데이터 파싱 실패:', error);
}
}
// 3. localStorage에서 확인 (영구 저장)
const localData = localStorage.getItem(this.STORAGE_KEY);
if (localData) {
try {
const parsedData = JSON.parse(localData);
(window as any).globalFileRegistry = parsedData;
return parsedData;
} catch (error) {
console.warn('로컬 파일 데이터 파싱 실패:', error);
}
}
return {};
}
/**
*
*/
static registerFile(fileInfo: GlobalFileInfo): void {
if (typeof window === 'undefined') return;
const registry = this.getRegistry();
registry[fileInfo.objid] = fileInfo;
// 메모리, 세션, 로컬스토리지에 모두 저장
(window as any).globalFileRegistry = registry;
sessionStorage.setItem(this.SESSION_STORAGE_KEY, JSON.stringify(registry));
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(registry));
console.log(`🌐 파일 등록됨: ${fileInfo.savedFileName} (총 ${Object.keys(registry).length}개)`);
}
/**
*
*/
static registerFiles(files: FileInfo[], context: {
uploadPage: string;
componentId: string;
screenId?: number;
}): void {
files.forEach(file => {
const globalFileInfo: GlobalFileInfo = {
...file,
uploadPage: context.uploadPage,
uploadTime: new Date().toISOString(),
componentId: context.componentId,
screenId: context.screenId,
accessible: true,
};
this.registerFile(globalFileInfo);
});
}
/**
*
*/
static getAllAccessibleFiles(): GlobalFileInfo[] {
const registry = this.getRegistry();
return Object.values(registry).filter(file => file.accessible);
}
/**
*
*/
static getFilesByPage(pagePath: string): GlobalFileInfo[] {
const registry = this.getRegistry();
return Object.values(registry).filter(file =>
file.uploadPage === pagePath && file.accessible
);
}
/**
*
*/
static getFilesByScreen(screenId: number): GlobalFileInfo[] {
const registry = this.getRegistry();
return Object.values(registry).filter(file =>
file.screenId === screenId && file.accessible
);
}
/**
* ()
*/
static searchFiles(query: string): GlobalFileInfo[] {
const registry = this.getRegistry();
const lowerQuery = query.toLowerCase();
return Object.values(registry).filter(file =>
file.accessible &&
(file.realFileName?.toLowerCase().includes(lowerQuery) ||
file.savedFileName?.toLowerCase().includes(lowerQuery))
);
}
/**
* ( )
*/
static setFileAccessible(fileId: string, accessible: boolean): void {
const registry = this.getRegistry();
if (registry[fileId]) {
registry[fileId].accessible = accessible;
// 저장소 업데이트
(window as any).globalFileRegistry = registry;
sessionStorage.setItem(this.SESSION_STORAGE_KEY, JSON.stringify(registry));
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(registry));
}
}
/**
*
*/
static clearRegistry(): void {
if (typeof window === 'undefined') return;
(window as any).globalFileRegistry = {};
sessionStorage.removeItem(this.SESSION_STORAGE_KEY);
localStorage.removeItem(this.STORAGE_KEY);
console.log('🧹 전역 파일 저장소 초기화됨');
}
/**
*
*/
static getRegistryInfo(): {
totalFiles: number;
accessibleFiles: number;
pages: string[];
screens: number[];
} {
const registry = this.getRegistry();
const files = Object.values(registry);
return {
totalFiles: files.length,
accessibleFiles: files.filter(f => f.accessible).length,
pages: [...new Set(files.map(f => f.uploadPage))],
screens: [...new Set(files.map(f => f.screenId).filter(Boolean) as number[])],
};
}
}

View File

@ -19,6 +19,14 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
const { webTypes } = useWebTypes({ active: "Y" });
// 디버깅: 전달받은 웹타입과 props 정보 로깅
console.log("🔍 DynamicWebTypeRenderer 호출:", {
webType,
propsKeys: Object.keys(props),
component: props.component,
isFileComponent: props.component?.type === "file" || webType === "file",
});
const webTypeDefinition = useMemo(() => {
return WebTypeRegistry.getWebType(webType);
}, [webType]);
@ -47,6 +55,17 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
};
}, [props, mergedConfig, webType, onEvent]);
// 0순위: 파일 컴포넌트 강제 처리 (최우선)
if (webType === "file" || props.component?.type === "file") {
try {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`🎯 최우선: 파일 컴포넌트 → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />;
} catch (error) {
console.error("FileUploadComponent 로드 실패:", error);
}
}
// 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선)
if (dbWebType?.component_name) {
try {
@ -65,7 +84,9 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
// return <ComponentByName {...props} {...finalProps} />;
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
return <div> ...</div>;
// 로딩 중 메시지 대신 레지스트리로 폴백
// return <div>컴포넌트 로딩 중...</div>;
} catch (error) {
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
}
@ -114,10 +135,31 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
return <FileUploadComponent {...props} {...finalProps} />;
}
// const FallbackComponent = getWidgetComponentByWebType(webType);
// return <FallbackComponent {...props} />;
console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`);
return <div> ...</div>;
// 텍스트 입력 웹타입들
if (["text", "email", "password", "tel"].includes(webType)) {
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
console.log(`✅ 폴백: ${webType} 웹타입 → TextInputComponent 사용`);
return <TextInputComponent {...props} {...finalProps} />;
}
// 숫자 입력 웹타입들
if (["number", "decimal"].includes(webType)) {
const { NumberInputComponent } = require("@/lib/registry/components/number-input/NumberInputComponent");
console.log(`✅ 폴백: ${webType} 웹타입 → NumberInputComponent 사용`);
return <NumberInputComponent {...props} {...finalProps} />;
}
// 날짜 입력 웹타입들
if (["date", "datetime", "time"].includes(webType)) {
const { DateInputComponent } = require("@/lib/registry/components/date-input/DateInputComponent");
console.log(`✅ 폴백: ${webType} 웹타입 → DateInputComponent 사용`);
return <DateInputComponent {...props} {...finalProps} />;
}
// 기본 폴백: Input 컴포넌트 사용
const { Input } = require("@/components/ui/input");
console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...props} />;
} catch (error) {
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
return (

View File

@ -244,33 +244,39 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
);
}
// 컨테이너 스타일 (원래 카드 레이아웃과 완전히 동일)
// 컨테이너 스타일 - 통일된 디자인 시스템 적용
const containerStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
gap: `${componentConfig.cardSpacing || 16}px`,
padding: "16px",
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌
padding: "32px", // 패딩 대폭 증가
width: "100%",
height: "100%",
background: "transparent",
background: "linear-gradient(to br, #f8fafc, #f1f5f9)", // 부드러운 그라데이션 배경
overflow: "auto",
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
};
// 카드 스타일 (원래 카드 레이아웃과 완전히 동일)
// 카드 스타일 - 통일된 디자인 시스템 적용
const cardStyle: React.CSSProperties = {
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
transition: "all 0.2s ease-in-out",
border: "1px solid #e2e8f0", // 더 부드러운 보더 색상
borderRadius: "12px", // 통일된 라운드 처리
padding: "24px", // 더 여유로운 패딩
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", // 더 깊은 그림자
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // 부드러운 트랜지션
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
minHeight: "200px",
minHeight: "240px", // 최소 높이 더 증가
cursor: isDesignMode ? "pointer" : "default",
// 호버 효과를 위한 추가 스타일
"&:hover": {
transform: "translateY(-2px)",
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
}
};
// 텍스트 자르기 함수
@ -386,53 +392,53 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
<div
key={data.id || index}
style={cardStyle}
className="card-hover"
className="group cursor-pointer hover:transform hover:-translate-y-1 hover:shadow-xl transition-all duration-300 ease-out"
onClick={() => handleCardClick(data)}
>
{/* 카드 이미지 */}
{/* 카드 이미지 - 통일된 디자인 */}
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
<div className="mb-3 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
<span className="text-xl text-gray-500">👤</span>
<div className="mb-4 flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-blue-100 to-indigo-100 shadow-sm border-2 border-white">
<span className="text-2xl text-blue-600">👤</span>
</div>
</div>
)}
{/* 카드 타이틀 */}
{/* 카드 타이틀 - 통일된 디자인 */}
{componentConfig.cardStyle?.showTitle && (
<div className="mb-2">
<h3 className="text-lg font-semibold text-gray-900">{titleValue}</h3>
<div className="mb-3">
<h3 className="text-xl font-bold text-gray-900 leading-tight">{titleValue}</h3>
</div>
)}
{/* 카드 서브타이틀 */}
{/* 카드 서브타이틀 - 통일된 디자인 */}
{componentConfig.cardStyle?.showSubtitle && (
<div className="mb-2">
<p className="text-sm font-medium text-blue-600">{subtitleValue}</p>
<div className="mb-3">
<p className="text-sm font-semibold text-blue-600 bg-blue-50 px-3 py-1 rounded-full inline-block">{subtitleValue}</p>
</div>
)}
{/* 카드 설명 */}
{/* 카드 설명 - 통일된 디자인 */}
{componentConfig.cardStyle?.showDescription && (
<div className="mb-3 flex-1">
<p className="text-sm leading-relaxed text-gray-600">
<div className="mb-4 flex-1">
<p className="text-sm leading-relaxed text-gray-700 bg-gray-50 p-3 rounded-lg">
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
</p>
</div>
)}
{/* 추가 표시 컬럼들 */}
{/* 추가 표시 컬럼들 - 통일된 디자인 */}
{componentConfig.columnMapping?.displayColumns &&
componentConfig.columnMapping.displayColumns.length > 0 && (
<div className="space-y-1 border-t border-gray-100 pt-3">
<div className="space-y-2 border-t border-gray-200 pt-4">
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
const value = getColumnValue(data, columnName);
if (!value) return null;
return (
<div key={idx} className="flex justify-between text-xs">
<span className="text-gray-500 capitalize">{getColumnLabel(columnName)}:</span>
<span className="font-medium text-gray-700">{value}</span>
<div key={idx} className="flex justify-between items-center text-sm bg-white/50 px-3 py-2 rounded-lg border border-gray-100">
<span className="text-gray-600 font-medium capitalize">{getColumnLabel(columnName)}:</span>
<span className="font-semibold text-gray-900 bg-gray-100 px-2 py-1 rounded-md text-xs">{value}</span>
</div>
);
})}

View File

@ -0,0 +1,297 @@
"use client";
import React, { useState, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo, FileUploadConfig } from "./types";
import {
Upload,
Download,
Trash2,
Eye,
File,
FileText,
Image as ImageIcon,
Video,
Music,
Archive,
Presentation,
X
} from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
interface FileManagerModalProps {
isOpen: boolean;
onClose: () => void;
uploadedFiles: FileInfo[];
onFileUpload: (files: File[]) => Promise<void>;
onFileDownload: (file: FileInfo) => void;
onFileDelete: (file: FileInfo) => void;
onFileView: (file: FileInfo) => void;
config: FileUploadConfig;
isDesignMode?: boolean;
}
export const FileManagerModal: React.FC<FileManagerModalProps> = ({
isOpen,
onClose,
uploadedFiles,
onFileUpload,
onFileDownload,
onFileDelete,
onFileView,
config,
isDesignMode = false,
}) => {
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 파일 아이콘 가져오기
const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <ImageIcon className="w-5 h-5 text-blue-500" />;
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
return <FileText className="w-5 h-5 text-red-500" />;
} else if (['xls', 'xlsx', 'csv'].includes(ext)) {
return <FileText className="w-5 h-5 text-green-500" />;
} else if (['ppt', 'pptx'].includes(ext)) {
return <Presentation className="w-5 h-5 text-orange-500" />;
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) {
return <Video className="w-5 h-5 text-purple-500" />;
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
return <Music className="w-5 h-5 text-pink-500" />;
} else if (['zip', 'rar', '7z'].includes(ext)) {
return <Archive className="w-5 h-5 text-yellow-500" />;
} else {
return <File className="w-5 h-5 text-gray-500" />;
}
};
// 파일 업로드 핸들러
const handleFileUpload = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
setUploading(true);
try {
const fileArray = Array.from(files);
await onFileUpload(fileArray);
console.log('✅ FileManagerModal: 파일 업로드 완료');
} catch (error) {
console.error('❌ FileManagerModal: 파일 업로드 오류:', error);
} finally {
setUploading(false);
console.log('🔄 FileManagerModal: 업로드 상태 초기화');
}
};
// 드래그 앤 드롭 핸들러
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (config.disabled || isDesignMode) return;
const files = e.dataTransfer.files;
handleFileUpload(files);
};
// 파일 선택 핸들러
const handleFileSelect = () => {
if (config.disabled || isDesignMode) return;
fileInputRef.current?.click();
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
handleFileUpload(files);
}
// 입력값 초기화
e.target.value = '';
};
// 파일 뷰어 핸들러
const handleFileViewInternal = (file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
};
const handleViewerClose = () => {
setIsViewerOpen(false);
setViewerFile(null);
};
return (
<>
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<DialogTitle className="text-lg font-semibold">
({uploadedFiles.length})
</DialogTitle>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-gray-100"
onClick={onClose}
title="닫기"
>
<X className="w-4 h-4" />
</Button>
</DialogHeader>
<div className="flex flex-col space-y-4 h-[70vh]">
{/* 파일 업로드 영역 */}
{!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploading ? 'opacity-75' : ''}
`}
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple={config.multiple}
accept={config.accept}
onChange={handleFileInputChange}
className="hidden"
disabled={config.disabled}
/>
{uploading ? (
<div className="flex flex-col items-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
<span className="text-blue-600 font-medium"> ...</span>
</div>
) : (
<div className="flex flex-col items-center">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-900 mb-2">
</p>
<p className="text-sm text-gray-500">
{config.accept && `지원 형식: ${config.accept}`}
{config.maxSize && ` • 최대 ${formatFileSize(config.maxSize)}`}
{config.multiple && ' • 여러 파일 선택 가능'}
</p>
</div>
)}
</div>
)}
{/* 파일 목록 */}
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-700">
</h3>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
</div>
{uploadedFiles.length > 0 ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.objid}
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{file.realFileName}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(file.fileSize)} {file.fileExt.toUpperCase()}
</p>
</div>
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleFileViewInternal(file)}
title="미리보기"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => onFileDownload(file)}
title="다운로드"
>
<Download className="w-4 h-4" />
</Button>
{!isDesignMode && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
onClick={() => onFileDelete(file)}
title="삭제"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<File className="w-16 h-16 mb-4 text-gray-300" />
<p className="text-lg font-medium text-gray-600"> </p>
<p className="text-sm text-gray-500 mt-2">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 드래그하거나 클릭하여 업로드하세요'}
</p>
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* 파일 뷰어 모달 */}
<FileViewerModal
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={onFileDownload}
onDelete={!isDesignMode ? onFileDelete : undefined}
/>
</>
);
};

View File

@ -3,9 +3,11 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file";
import { GlobalFileManager } from "@/lib/api/globalFile";
import { formatFileSize } from "@/lib/utils";
import { FileViewerModal } from "./FileViewerModal";
import { FileManagerModal } from "./FileManagerModal";
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
import {
Upload,
@ -75,13 +77,13 @@ export interface FileUploadComponentProps {
onConfigChange?: (config: any) => void;
}
export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
component,
componentConfig,
componentStyle,
className,
isInteractive,
isDesignMode,
isDesignMode = false, // 기본값 설정
formData,
onFormDataChange,
onClick,
@ -94,55 +96,305 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [dragOver, setDragOver] = useState(false);
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
useEffect(() => {
if (!component?.id) return;
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
componentId: component.id,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원
if (typeof window !== 'undefined') {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: parsedFiles
};
}
}
}
} catch (e) {
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
}
}, [component.id]); // component.id가 변경될 때만 실행
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
useEffect(() => {
const handleDesignModeFileChange = (event: CustomEvent) => {
console.log("🎯🎯🎯 FileUploadComponent 화면설계 모드 파일 변경 이벤트 수신:", {
eventComponentId: event.detail.componentId,
currentComponentId: component.id,
isMatch: event.detail.componentId === component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action,
source: event.detail.source,
eventDetail: event.detail
});
// 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
if (event.detail.componentId === component.id && event.detail.source === 'designMode') {
console.log("✅✅✅ 화면설계 모드 → 실제 화면 파일 동기화 시작:", {
componentId: component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action
});
// 파일 상태 업데이트
const newFiles = event.detail.files || [];
setUploadedFiles(newFiles);
// localStorage 백업 업데이트
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(newFiles));
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
componentId: component.id,
fileCount: newFiles.length
});
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트
if (typeof window !== 'undefined') {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: newFiles
};
}
// onUpdate 콜백 호출 (부모 컴포넌트에 알림)
if (onUpdate) {
onUpdate({
uploadedFiles: newFiles,
lastFileUpdate: event.detail.timestamp
});
}
console.log("🎉🎉🎉 화면설계 모드 → 실제 화면 동기화 완료:", {
componentId: component.id,
finalFileCount: newFiles.length
});
}
};
if (typeof window !== 'undefined') {
window.addEventListener('globalFileStateChanged', handleDesignModeFileChange as EventListener);
return () => {
window.removeEventListener('globalFileStateChanged', handleDesignModeFileChange as EventListener);
};
}
}, [component.id, onUpdate]);
// 템플릿 파일과 데이터 파일을 조회하는 함수
const loadComponentFiles = useCallback(async () => {
if (!component?.id) return;
try {
let screenId = formData?.screenId || (typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
? parseInt(window.location.pathname.split('/screens/')[1])
: null);
// 디자인 모드인 경우 기본 화면 ID 사용
if (!screenId && isDesignMode) {
screenId = 40; // 기본 화면 ID
console.log("📂 디자인 모드: 기본 화면 ID 사용 (40)");
}
if (!screenId) {
console.log("📂 화면 ID 없음, 기존 파일 로직 사용");
return false; // 기존 로직 사용
}
const params = {
screenId,
componentId: component.id,
tableName: formData?.tableName || component.tableName,
recordId: formData?.id,
columnName: component.columnName,
};
console.log("📂 컴포넌트 파일 조회:", params);
const response = await getComponentFiles(params);
if (response.success) {
console.log("📁 파일 조회 결과:", {
templateFiles: response.templateFiles.length,
dataFiles: response.dataFiles.length,
totalFiles: response.totalFiles.length,
summary: response.summary,
actualFiles: response.totalFiles
});
// 파일 데이터 형식 통일
const formattedFiles = response.totalFiles.map((file: any) => ({
objid: file.objid || file.id,
savedFileName: file.savedFileName || file.saved_file_name,
realFileName: file.realFileName || file.real_file_name,
fileSize: file.fileSize || file.file_size,
fileExt: file.fileExt || file.file_ext,
regdate: file.regdate,
status: file.status || 'ACTIVE',
uploadedAt: file.uploadedAt || new Date().toISOString(),
...file
}));
console.log("📁 형식 변환된 파일 데이터:", formattedFiles);
// 🔄 localStorage의 기존 파일과 서버 파일 병합
let finalFiles = formattedFiles;
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedBackupFiles = JSON.parse(backupFiles);
// 서버에 없는 localStorage 파일들을 추가 (objid 기준으로 중복 제거)
const serverObjIds = new Set(formattedFiles.map((f: any) => f.objid));
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
finalFiles = [...formattedFiles, ...additionalFiles];
console.log("🔄 파일 병합 완료:", {
서버파일: formattedFiles.length,
로컬파일: parsedBackupFiles.length,
추가파일: additionalFiles.length,
최종파일: finalFiles.length,
최종파일목록: finalFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
}
} catch (e) {
console.warn("파일 병합 중 오류:", e);
}
setUploadedFiles(finalFiles);
// 전역 상태에도 저장
if (typeof window !== 'undefined') {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: finalFiles
};
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
GlobalFileManager.registerFiles(finalFiles, {
uploadPage: window.location.pathname,
componentId: component.id,
screenId: formData?.screenId,
});
// localStorage 백업도 병합된 파일로 업데이트
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(finalFiles));
console.log("💾 localStorage 백업 업데이트 완료:", finalFiles.length);
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
}
return true; // 새로운 로직 사용됨
}
} catch (error) {
console.error("파일 조회 오류:", error);
}
return false; // 기존 로직 사용
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]);
// 컴포넌트 파일 동기화
useEffect(() => {
const componentFiles = (component as any)?.uploadedFiles || [];
const lastUpdate = (component as any)?.lastFileUpdate;
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
console.log("🔄 FileUploadComponent 파일 동기화:", {
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
componentId: component.id,
componentFiles: componentFiles.length,
globalFiles: globalFiles.length,
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
lastUpdate: lastUpdate
formData: formData,
screenId: formData?.screenId,
currentUploadedFiles: uploadedFiles.length
});
// localStorage에서 백업 파일 복원
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles && currentFiles.length === 0) {
const parsedFiles = JSON.parse(backupFiles);
setUploadedFiles(parsedFiles);
return;
// 먼저 새로운 템플릿 파일 조회 시도
loadComponentFiles().then(useNewLogic => {
if (useNewLogic) {
console.log("✅ 새로운 템플릿 파일 로직 사용");
return; // 새로운 로직이 성공했으면 기존 로직 스킵
}
} catch (e) {
console.warn("localStorage 백업 복원 실패:", e);
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
// 기존 로직 사용
console.log("📂 기존 파일 로직 사용");
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
console.log("🔄 FileUploadComponent 파일 동기화:", {
componentId: component.id,
componentFiles: componentFiles.length,
globalFiles: globalFiles.length,
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
lastUpdate: lastUpdate
});
setUploadedFiles(currentFiles);
setForceUpdate(prev => prev + 1);
}
}, [component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
// localStorage에서 백업 파일 복원 (새로고침 시 중요!)
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0 && currentFiles.length === 0) {
console.log("🔄 localStorage에서 파일 복원:", {
componentId: component.id,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원
if (typeof window !== 'undefined') {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: parsedFiles
};
}
return;
}
}
} catch (e) {
console.warn("localStorage 백업 복원 실패:", e);
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(currentFiles);
setForceUpdate(prev => prev + 1);
}
});
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
useEffect(() => {
@ -164,9 +416,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
console.log(logMessage, {
componentId: component.id,
이전파일수: uploadedFiles.length,
새파일수: files.length,
files: files.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
이전파일수: uploadedFiles?.length || 0,
새파일수: files?.length || 0,
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || []
});
setUploadedFiles(files);
@ -203,8 +455,18 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 파일 선택 핸들러
const handleFileSelect = useCallback(() => {
console.log("🎯 handleFileSelect 호출됨:", {
hasFileInputRef: !!fileInputRef.current,
fileInputRef: fileInputRef.current,
fileInputType: fileInputRef.current?.type,
fileInputHidden: fileInputRef.current?.className
});
if (fileInputRef.current) {
console.log("✅ fileInputRef.current.click() 호출");
fileInputRef.current.click();
} else {
console.log("❌ fileInputRef.current가 null입니다");
}
}, []);
@ -265,21 +527,40 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
toast.loading("파일을 업로드하는 중...", { id: 'file-upload' });
try {
// targetObjid 생성 (InteractiveDataTable과 호환)
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
const tableName = formData?.tableName || component.tableName || 'default_table';
const recordId = formData?.id || 'temp_record';
const recordId = formData?.id;
const screenId = formData?.screenId;
const columnName = component.columnName || component.id;
const targetObjid = `${tableName}:${recordId}:${columnName}`;
let targetObjid;
if (recordId && tableName) {
// 실제 데이터 파일
targetObjid = `${tableName}:${recordId}:${columnName}`;
console.log("📁 실제 데이터 파일 업로드:", targetObjid);
} else if (screenId) {
// 템플릿 파일
targetObjid = `screen_${screenId}:${component.id}`;
console.log("🎨 템플릿 파일 업로드:", targetObjid);
} else {
// 기본값 (화면관리에서 사용)
targetObjid = `temp_${component.id}`;
console.log("📝 기본 파일 업로드:", targetObjid);
}
const uploadData = {
tableName: tableName,
fieldName: columnName,
recordId: recordId,
// 🎯 formData에서 백엔드 API 설정 가져오기
autoLink: formData?.autoLink || true,
linkedTable: formData?.linkedTable || tableName,
recordId: formData?.recordId || recordId || `temp_${component.id}`,
columnName: formData?.columnName || columnName,
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
docType: component.fileConfig?.docType || 'DOCUMENT',
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
// 호환성을 위한 기존 필드들
tableName: tableName,
fieldName: columnName,
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
columnName: columnName, // 가상 파일 컬럼 지원
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
};
console.log("📤 파일 업로드 시작:", {
@ -358,6 +639,13 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
globalFileState[component.id] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
GlobalFileManager.registerFiles(newFiles, {
uploadPage: window.location.pathname,
componentId: component.id,
screenId: formData?.screenId,
});
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent('globalFileStateChanged', {
detail: {
@ -429,6 +717,11 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (safeComponentConfig.onFileUpload) {
safeComponentConfig.onFileUpload(newFiles);
}
// 성공 시 토스트 처리
setUploadStatus('idle');
toast.dismiss('file-upload');
toast.success(`${newFiles.length}개 파일 업로드 완료`);
} else {
console.error("❌ 파일 업로드 실패:", response);
throw new Error(response.message || (response as any).error || '파일 업로드에 실패했습니다.');
@ -436,15 +729,31 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
} catch (error) {
console.error('파일 업로드 오류:', error);
setUploadStatus('error');
toast.dismiss();
toast.dismiss('file-upload');
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
}
}, [safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData]);
// 파일 뷰어 열기
const handleFileView = useCallback((file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
}, []);
// 파일 뷰어 닫기
const handleViewerClose = useCallback(() => {
setIsViewerOpen(false);
setViewerFile(null);
}, []);
// 파일 다운로드
const handleFileDownload = useCallback(async (file: FileInfo) => {
try {
await downloadFile(file.objid, file.realFileName);
await downloadFile({
fileId: file.objid,
serverFilename: file.savedFileName,
originalName: file.realFileName
});
toast.success(`${file.realFileName} 다운로드 완료`);
} catch (error) {
console.error('파일 다운로드 오류:', error);
@ -458,7 +767,8 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const fileId = typeof file === 'string' ? file : file.objid;
const fileName = typeof file === 'string' ? '파일' : file.realFileName;
await deleteFile(fileId);
const serverFilename = typeof file === 'string' ? 'temp_file' : file.savedFileName;
await deleteFile(fileId, serverFilename);
const updatedFiles = uploadedFiles.filter(f => f.objid !== fileId);
setUploadedFiles(updatedFiles);
@ -484,7 +794,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
componentId: component.id,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now()
timestamp: Date.now(),
source: 'realScreen', // 🎯 실제 화면에서 온 이벤트임을 표시
action: 'delete'
}
});
window.dispatchEvent(syncEvent);
@ -512,25 +824,23 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
}, [uploadedFiles, onUpdate, component.id]);
// 파일 뷰어
const handleFileView = useCallback((file: FileInfo) => {
setViewerFile(file);
setIsViewerOpen(true);
}, []);
const handleViewerClose = useCallback(() => {
setIsViewerOpen(false);
setViewerFile(null);
}, []);
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => {
console.log("🎯 드래그 오버 이벤트 감지:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
dragOver: dragOver
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
setDragOver(true);
console.log("✅ 드래그 오버 활성화");
} else {
console.log("❌ 드래그 차단됨: readonly 또는 disabled");
}
}, [safeComponentConfig.readonly, safeComponentConfig.disabled]);
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -553,27 +863,53 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 클릭 핸들러
const handleClick = useCallback((e: React.MouseEvent) => {
console.log("🖱️ 파일 업로드 영역 클릭:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
hasHandleFileSelect: !!handleFileSelect
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
console.log("✅ 파일 선택 함수 호출");
handleFileSelect();
} else {
console.log("❌ 클릭 차단됨: readonly 또는 disabled");
}
onClick?.();
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick]);
return (
<div style={componentStyle} className={className}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<div
style={{
...componentStyle,
border: 'none !important',
boxShadow: 'none !important',
outline: 'none !important',
backgroundColor: 'transparent !important',
padding: '0px !important',
borderRadius: '0px !important',
marginBottom: '8px !important'
}}
className={`${className} file-upload-container`}
>
{/* 라벨 렌더링 - 주석처리 */}
{/* {component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
top: "-25px",
top: "-20px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#3b83f6",
fontWeight: "500",
...(isInteractive && component.style ? component.style : {}),
fontSize: "12px",
color: "rgb(107, 114, 128)",
fontWeight: "400",
background: "transparent !important",
border: "none !important",
boxShadow: "none !important",
outline: "none !important",
padding: "0px !important",
margin: "0px !important"
}}
>
{component.label}
@ -581,18 +917,22 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)}
)} */}
<div className="w-full h-full flex flex-col space-y-2">
{/* 디자인 모드가 아닐 때만 파일 업로드 영역 표시 */}
{!isDesignMode && (
<div
className="w-full h-full flex flex-col space-y-2"
style={{ minHeight: '120px' }}
>
{/* 파일 업로드 영역 - 주석처리 */}
{/* {!isDesignMode && (
<div
className={`
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
border border-dashed rounded p-2 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
`}
style={{ minHeight: '50px' }}
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -603,13 +943,13 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<input
ref={fileInputRef}
type="file"
multiple={safeComponentConfig.multiple}
accept={safeComponentConfig.accept}
onChange={handleInputChange}
multiple={safeComponentConfig.multiple}
accept={safeComponentConfig.accept}
onChange={handleInputChange}
className="hidden"
disabled={safeComponentConfig.disabled}
/>
{uploadStatus === 'uploading' ? (
<div className="flex flex-col items-center space-y-2">
<div className="flex items-center space-x-2">
@ -620,60 +960,82 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
) : (
<>
<div>
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-lg font-medium text-gray-900 mb-2">
{safeComponentConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요"}
</p>
<p className="text-xs text-gray-500 mt-1">
{safeComponentConfig.accept && `지원 형식: ${safeComponentConfig.accept}`}
{safeComponentConfig.maxSize && ` • 최대 ${formatFileSize(safeComponentConfig.maxSize)}`}
{safeComponentConfig.multiple && ' • 여러 파일 선택 가능'}
<Upload className="mx-auto h-6 w-6 text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-600">
</p>
</div>
</>
)}
</div>
)}
)} */}
{/* 업로드된 파일 목록 - 디자인 모드에서는 항상 표시 */}
{(uploadedFiles.length > 0 || isDesignMode) && (
{/* 업로드된 파일 목록 - 항상 표시 */}
{(() => {
const shouldShow = true; // 항상 표시하도록 강제
console.log("🎯🎯🎯 파일 목록 렌더링 조건 체크:", {
uploadedFilesLength: uploadedFiles.length,
isDesignMode: isDesignMode,
shouldShow: shouldShow,
uploadedFiles: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName })),
"🚨 렌더링 여부": shouldShow ? "✅ 렌더링됨" : "❌ 렌더링 안됨"
});
return shouldShow;
})() && (
<div className="flex-1 overflow-y-auto">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-700">
<h4 className="text-sm font-medium text-gray-700" style={{ textShadow: 'none', boxShadow: 'none' }}>
({uploadedFiles.length})
</h4>
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
<div className="flex items-center space-x-2">
{uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
</Badge>
)}
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setIsFileManagerOpen(true)}
style={{
boxShadow: 'none !important',
textShadow: 'none !important',
filter: 'none !important',
WebkitBoxShadow: 'none !important',
MozBoxShadow: 'none !important'
}}
>
</Button>
</div>
</div>
{uploadedFiles.length > 0 ? (
<div className="space-y-1">
{uploadedFiles.map((file) => (
<div key={file.objid} className="flex items-center space-x-2 p-2 bg-gray-50 rounded text-sm">
<div key={file.objid} className="flex items-center space-x-3 p-2 bg-gray-50 rounded text-sm hover:bg-gray-100 transition-colors" style={{ boxShadow: 'none', textShadow: 'none' }}>
<div className="flex-shrink-0">
{getFileIcon(file.fileExt)}
</div>
<span className="flex-1 truncate text-gray-900">
<span className="flex-1 truncate text-gray-900 cursor-pointer" onClick={() => handleFileView(file)} style={{ textShadow: 'none' }}>
{file.realFileName}
</span>
<span className="text-xs text-gray-500">
<span className="text-xs text-gray-500" style={{ textShadow: 'none' }}>
{formatFileSize(file.fileSize)}
</span>
</div>
))}
<div className="text-xs text-gray-500 mt-2 text-center">
💡
<div className="text-xs text-gray-500 mt-2 text-center" style={{ textShadow: 'none' }}>
💡 "전체 자세히보기"
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
<div className="flex flex-col items-center justify-center py-8 text-gray-500" style={{ textShadow: 'none' }}>
<File className="w-12 h-12 mb-3 text-gray-300" />
<p className="text-sm font-medium"> </p>
<p className="text-xs text-gray-400 mt-1"> </p>
<p className="text-sm font-medium" style={{ textShadow: 'none' }}> </p>
<p className="text-xs text-gray-400 mt-1" style={{ textShadow: 'none' }}> </p>
</div>
)}
</div>
@ -694,6 +1056,20 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
onDelete={!isDesignMode ? handleFileDelete : undefined}
/>
{/* 파일 관리 모달 */}
<FileManagerModal
isOpen={isFileManagerOpen}
onClose={() => setIsFileManagerOpen(false)}
uploadedFiles={uploadedFiles}
onFileUpload={handleFileUpload}
onFileDownload={handleFileDownload}
onFileDelete={handleFileDelete}
onFileView={handleFileView}
config={safeComponentConfig}
isDesignMode={isDesignMode}
/>
</div>
);

View File

@ -1,39 +1,176 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileInfo } from "./types";
import { Download, X, AlertTriangle, FileText, Image as ImageIcon } from "lucide-react";
import { Download, X, AlertTriangle, FileText, Trash2, ExternalLink } from "lucide-react";
import { formatFileSize } from "@/lib/utils";
import { API_BASE_URL } from "@/lib/api/client";
// Office 문서 렌더링을 위한 CDN 라이브러리 로드
const loadOfficeLibrariesFromCDN = async () => {
if (typeof window === 'undefined') return { XLSX: null, mammoth: null };
try {
// XLSX 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).XLSX) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// mammoth 라이브러리가 이미 로드되어 있는지 확인
if (!(window as any).mammoth) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.4.2/mammoth.browser.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
return {
XLSX: (window as any).XLSX,
mammoth: (window as any).mammoth
};
} catch (error) {
console.error('Office 라이브러리 CDN 로드 실패:', error);
return { XLSX: null, mammoth: null };
}
};
interface FileViewerModalProps {
file: FileInfo | null;
isOpen: boolean;
onClose: () => void;
onDownload?: (file: FileInfo) => void;
onDelete?: (file: FileInfo) => void;
}
/**
*
*
*/
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
file,
isOpen,
onClose,
onDownload,
onDelete,
}) => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [renderedContent, setRenderedContent] = useState<string | null>(null);
// Office 문서를 CDN 라이브러리로 렌더링하는 함수
const renderOfficeDocument = async (blob: Blob, fileExt: string, fileName: string) => {
try {
setIsLoading(true);
// CDN에서 라이브러리 로드
const { XLSX, mammoth } = await loadOfficeLibrariesFromCDN();
if (fileExt === "docx" && mammoth) {
// Word 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
const htmlContent = `
<div>
<h4 style="margin: 0 0 15px 0; color: #333; font-size: 16px;">📄 ${fileName}</h4>
<div class="word-content" style="max-height: 500px; overflow-y: auto; padding: 20px; background: white; border: 1px solid #ddd; border-radius: 5px; line-height: 1.6; font-family: 'Times New Roman', serif;">
${result.value || '내용을 읽을 수 없습니다.'}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["xlsx", "xls"].includes(fileExt) && XLSX) {
// Excel 문서 렌더링
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(worksheet, {
table: { className: 'excel-table' }
});
const htmlContent = `
<div>
<h4 style="margin: 0 0 10px 0; color: #333; font-size: 16px;">📊 ${fileName}</h4>
<p style="margin: 0 0 15px 0; color: #666; font-size: 14px;">시트: ${sheetName}</p>
<div style="max-height: 400px; overflow: auto; border: 1px solid #ddd; border-radius: 5px;">
<style>
.excel-table { border-collapse: collapse; width: 100%; }
.excel-table td, .excel-table th { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 12px; }
.excel-table th { background-color: #f5f5f5; font-weight: bold; }
</style>
${html}
</div>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (fileExt === "doc") {
// .doc 파일은 .docx로 변환 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📄 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">.doc .docx로 .</p>
<p style="color: #666; font-size: 14px;">(.docx )</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
} else if (["ppt", "pptx"].includes(fileExt)) {
// PowerPoint는 미리보기 불가 안내
const htmlContent = `
<div style="text-align: center; padding: 40px;">
<h3 style="color: #333; margin-bottom: 15px;">📑 ${fileName}</h3>
<p style="color: #666; margin-bottom: 10px;">PowerPoint .</p>
<p style="color: #666; font-size: 14px;"> .</p>
</div>
`;
setRenderedContent(htmlContent);
return true;
}
return false; // 지원하지 않는 형식
} catch (error) {
console.error("Office 문서 렌더링 오류:", error);
const htmlContent = `
<div style="color: red; text-align: center; padding: 20px;">
Office .<br>
.
</div>
`;
setRenderedContent(htmlContent);
return true; // 오류 메시지라도 표시
} finally {
setIsLoading(false);
}
};
// 파일이 변경될 때마다 미리보기 URL 생성
useEffect(() => {
if (!file || !isOpen) {
setPreviewUrl(null);
setPreviewError(null);
setRenderedContent(null);
return;
}
@ -49,16 +186,18 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
return () => URL.revokeObjectURL(url);
}
let cleanup: (() => void) | undefined;
// 서버 파일인 경우 - 미리보기 API 호출
const generatePreviewUrl = async () => {
try {
const fileExt = file.fileExt.toLowerCase();
// 미리보기 지원 파일 타입 정의
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'odt', 'ods', 'odp', 'hwp', 'hwpx', 'hwpml', 'hcdt', 'hpt', 'pages', 'numbers', 'keynote'];
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv'];
const mediaExtensions = ['mp4', 'webm', 'ogg', 'mp3', 'wav'];
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const documentExtensions = ["pdf","doc", "docx", "xls", "xlsx", "ppt", "pptx", "rtf", "odt", "ods", "odp", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"];
const textExtensions = ["txt", "md", "json", "xml", "csv"];
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
const supportedExtensions = [
...imageExtensions,
@ -68,22 +207,97 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
];
if (supportedExtensions.includes(fileExt)) {
// 실제 환경에서는 파일 서빙 API 엔드포인트 사용
const url = `/api/files/preview/${file.objid}`;
setPreviewUrl(url);
// 이미지나 PDF는 인증된 요청으로 Blob 생성
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
try {
// 인증된 요청으로 파일 데이터 가져오기
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
"Authorization": `Bearer ${localStorage.getItem("authToken")}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
// 컴포넌트 언마운트 시 URL 정리를 위해 cleanup 함수 저장
cleanup = () => URL.revokeObjectURL(blobUrl);
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("파일 미리보기 로드 실패:", error);
setPreviewError("파일을 불러올 수 없습니다. 권한을 확인해주세요.");
}
} else if (documentExtensions.includes(fileExt)) {
// Office 문서는 OnlyOffice 또는 안정적인 뷰어 사용
try {
const response = await fetch(`${API_BASE_URL}/files/preview/${file.objid}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
},
});
if (response.ok) {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Office 문서를 위한 특별한 처리 - CDN 라이브러리 사용
if (["doc", "docx", "xls", "xlsx", "ppt", "pptx"].includes(fileExt)) {
// CDN 라이브러리로 클라이언트 사이드 렌더링 시도
try {
const renderSuccess = await renderOfficeDocument(blob, fileExt, file.realFileName);
if (!renderSuccess) {
// 렌더링 실패 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} catch (error) {
console.error("Office 문서 렌더링 중 오류:", error);
// 오류 발생 시 Blob URL 사용
setPreviewUrl(blobUrl);
}
} else {
// 기타 문서는 직접 Blob URL 사용
setPreviewUrl(blobUrl);
}
return () => URL.revokeObjectURL(blobUrl); // Cleanup function
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error("Office 문서 로드 실패:", error);
// 오류 발생 시 다운로드 옵션 제공
setPreviewError(`${fileExt.toUpperCase()} 문서를 미리보기할 수 없습니다. 다운로드하여 확인해주세요.`);
}
} else {
// 기타 파일은 다운로드 URL 사용
const url = `${API_BASE_URL.replace("/api", "")}/api/files/download/${file.objid}`;
setPreviewUrl(url);
}
} else {
// 지원하지 않는 파일 타입
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
}
} catch (error) {
console.error('미리보기 URL 생성 오류:', error);
setPreviewError('미리보기를 불러오는데 실패했습니다.');
console.error("미리보기 URL 생성 오류:", error);
setPreviewError("미리보기를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
generatePreviewUrl();
// cleanup 함수 반환
return () => {
if (cleanup) {
cleanup();
}
};
}, [file, isOpen]);
if (!file) return null;
@ -100,8 +314,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
if (previewError) {
return (
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
<AlertTriangle className="w-16 h-16 mb-4" />
<div className="flex flex-col items-center justify-center h-96">
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center">{previewError}</p>
<Button
@ -119,121 +333,163 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
const fileExt = file.fileExt.toLowerCase();
// 이미지 파일
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(fileExt)) {
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
return (
<div className="flex items-center justify-center max-h-96 overflow-hidden">
<img
src={previewUrl || ''}
src={previewUrl || ""}
alt={file.realFileName}
className="max-w-full max-h-full object-contain rounded-lg"
onError={() => setPreviewError('이미지를 불러올 수 없습니다.')}
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
onError={(e) => {
console.error("이미지 로드 오류:", previewUrl, e);
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
}}
onLoad={() => {
console.log("이미지 로드 성공:", previewUrl);
}}
/>
</div>
);
}
// 텍스트 파일
if (['txt', 'md', 'json', 'xml', 'csv'].includes(fileExt)) {
if (["txt", "md", "json", "xml", "csv"].includes(fileExt)) {
return (
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ''}
src={previewUrl || ""}
className="w-full h-full border rounded-lg"
title={`${file.realFileName} 미리보기`}
onError={() => setPreviewError('텍스트 파일을 불러올 수 없습니다.')}
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
/>
</div>
);
}
// PDF 파일
if (fileExt === 'pdf') {
if (fileExt === "pdf") {
return (
<div className="h-96">
<div className="h-96 overflow-auto">
<iframe
src={previewUrl || ''}
src={previewUrl || ""}
className="w-full h-full border rounded-lg"
title={`${file.realFileName} 미리보기`}
onError={() => setPreviewError('PDF 파일을 불러올 수 없습니다.')}
onError={() => setPreviewError("PDF 파일을 불러올 수 없습니다.")}
/>
</div>
);
}
// Microsoft Office, 한컴오피스, Apple iWork 문서 파일
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'hwpml', 'hcdt', 'hpt', 'pages', 'numbers', 'keynote'].includes(fileExt)) {
// Office 파일은 Google Docs Viewer 또는 Microsoft Office Online을 통해 미리보기
const officeViewerUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(previewUrl || '')}`;
return (
<div className="h-96">
<iframe
src={officeViewerUrl}
className="w-full h-full border rounded-lg"
title={`${file.realFileName} 미리보기`}
onError={() => setPreviewError('Office 문서를 불러올 수 없습니다. 파일을 다운로드하여 확인해주세요.')}
/>
</div>
);
}
// 기타 문서 파일 (RTF, ODT 등)
if (['rtf', 'odt', 'ods', 'odp'].includes(fileExt)) {
return (
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
<FileText className="w-16 h-16 mb-4 text-blue-500" />
<p className="text-lg font-medium mb-2">{file.fileExt.toUpperCase()} </p>
<p className="text-sm text-center mb-4">
{file.realFileName}
</p>
<div className="flex flex-col items-center space-y-2 text-xs text-gray-400">
<p> : {formatFileSize(file.fileSize)}</p>
<p> : {file.docTypeName || '일반 문서'}</p>
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe)
if (
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"].includes(fileExt)
) {
// CDN 라이브러리로 렌더링된 콘텐츠가 있는 경우
if (renderedContent) {
return (
<div className="relative h-96 overflow-auto">
<div
className="w-full h-full p-4 border rounded-lg bg-white"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
</div>
<Button
variant="outline"
onClick={() => onDownload?.(file)}
className="mt-4"
>
<Download className="w-4 h-4 mr-2" />
</Button>
);
}
// iframe 방식 (fallback)
return (
<div className="relative h-96 overflow-auto">
<iframe
src={previewUrl || ""}
className="w-full h-full border rounded-lg"
onError={() => {
console.log("iframe 오류 발생, fallback 옵션 제공");
setPreviewError("이 Office 문서는 브라우저에서 직접 미리보기할 수 없습니다. 다운로드하여 확인해주세요.");
}}
title={`${file.realFileName} 미리보기`}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
onLoad={() => setIsLoading(false)}
/>
{/* 로딩 상태 */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-gray-600">Office ...</p>
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
</div>
)}
{/* 오류 발생 시 fallback 옵션 */}
{previewError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white">
<FileText className="w-16 h-16 mb-4 text-orange-500" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center mb-4 text-gray-600">
{previewError}
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="ghost"
onClick={() => {
// 새 탭에서 파일 열기 시도
const link = document.createElement('a');
link.href = previewUrl || '';
link.target = '_blank';
link.click();
}}
>
<ExternalLink className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
</div>
);
}
// 비디오 파일
if (['mp4', 'webm', 'ogg'].includes(fileExt)) {
if (["mp4", "webm", "ogg"].includes(fileExt)) {
return (
<div className="flex items-center justify-center">
<video
controls
className="max-w-full max-h-96 rounded-lg"
onError={() => setPreviewError('비디오를 재생할 수 없습니다.')}
className="w-full max-h-96"
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
>
<source src={previewUrl || ''} type={`video/${fileExt}`} />
.
<source src={previewUrl || ""} type={`video/${fileExt}`} />
</video>
</div>
);
}
// 오디오 파일
if (['mp3', 'wav', 'ogg'].includes(fileExt)) {
if (["mp3", "wav", "ogg"].includes(fileExt)) {
return (
<div className="flex flex-col items-center justify-center h-96">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6">
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<audio
controls
className="w-full max-w-md"
onError={() => setPreviewError('오디오를 재생할 수 없습니다.')}
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
>
<source src={previewUrl || ''} type={`audio/${fileExt}`} />
.
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
</audio>
</div>
);
@ -241,8 +497,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
// 기타 파일 타입
return (
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
<FileText className="w-16 h-16 mb-4" />
<div className="flex flex-col items-center justify-center h-96">
<FileText className="w-16 h-16 mb-4 text-gray-400" />
<p className="text-lg font-medium mb-2"> </p>
<p className="text-sm text-center mb-4">
{file.fileExt.toUpperCase()} .
@ -259,8 +515,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto [&>button]:hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
@ -271,40 +527,54 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
{file.fileExt.toUpperCase()}
</Badge>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClose}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* 파일 정보 */}
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
<span>: {formatFileSize(file.fileSize)}</span>
{file.uploadedAt && (
<span>: {new Date(file.uploadedAt).toLocaleString()}</span>
)}
{file.writer && <span>: {file.writer}</span>}
</div>
<DialogDescription>
: {formatFileSize(file.size)} | : {file.fileExt.toUpperCase()}
</DialogDescription>
</DialogHeader>
{/* 파일 미리보기 영역 */}
<div className="flex-1 overflow-auto py-4">
<div className="flex-1 overflow-y-auto">
{renderPreview()}
</div>
{/* 파일 정보 및 액션 버튼 */}
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
<span>: {formatFileSize(file.size)}</span>
{file.uploadedAt && (
<span>: {new Date(file.uploadedAt).toLocaleString()}</span>
)}
</div>
<div className="flex justify-end space-x-2 pt-4 border-t">
<Button
variant="outline"
size="sm"
onClick={() => onDownload?.(file)}
>
<Download className="w-4 h-4 mr-2" />
</Button>
{onDelete && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => onDelete(file)}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onClose}
>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
</DialogContent>
</Dialog>
);
};
};

View File

@ -13,14 +13,14 @@ export interface FileInfo {
fileSize: number;
fileExt: string;
filePath: string;
docType: string;
docTypeName: string;
docType?: string;
docTypeName?: string;
targetObjid: string;
parentTargetObjid?: string;
companyCode: string;
writer: string;
regdate: string;
status: string;
companyCode?: string;
writer?: string;
regdate?: string;
status?: string;
// 추가 호환성 속성들
path?: string; // filePath와 동일
@ -97,6 +97,7 @@ export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
export interface FileUploadResponse {
success: boolean;
data?: FileInfo[];
files?: FileInfo[];
message?: string;
error?: string;
}

View File

@ -46,7 +46,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
return (
<div
className="relative h-full overflow-auto"
className="relative h-full overflow-auto rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm"
style={{
width: "100%",
maxWidth: "100%",
@ -62,8 +62,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
boxSizing: "border-box",
}}
>
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<TableRow>
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-gradient-to-r from-slate-50 to-blue-50/30 border-b border-gray-200/60" : "bg-gradient-to-r from-slate-50 to-blue-50/30 border-b border-gray-200/60"}>
<TableRow className="border-b border-gray-200/40">
{visibleColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = visibleColumns
@ -84,13 +84,13 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "h-10 border-b px-4 py-2 text-center align-middle"
: "h-10 cursor-pointer border-b px-4 py-2 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
? "h-12 border-0 px-4 py-3 text-center align-middle"
: "h-12 cursor-pointer border-0 px-4 py-3 text-left align-middle font-semibold whitespace-nowrap text-slate-700 select-none transition-all duration-200",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
column.sortable && "hover:bg-blue-50/50 hover:text-blue-700",
// 고정 컬럼 스타일
column.fixed === "left" && "sticky z-10 border-r bg-white shadow-sm",
column.fixed === "right" && "sticky z-10 border-l bg-white shadow-sm",
column.fixed === "left" && "sticky z-10 border-r border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm",
column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
@ -118,15 +118,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
{columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && (
<span className="ml-1">
<span className="ml-2 flex h-5 w-5 items-center justify-center rounded-md bg-white/50 shadow-sm">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3 text-blue-600" />
<ArrowUp className="h-3.5 w-3.5 text-blue-600" />
) : (
<ArrowDown className="h-3 w-3 text-blue-600" />
<ArrowDown className="h-3.5 w-3.5 text-blue-600" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
<ArrowUpDown className="h-3.5 w-3.5 text-gray-400" />
)}
</span>
)}
@ -142,8 +142,16 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-8 text-center text-gray-500">
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-gray-100 to-gray-200">
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<span className="text-sm font-medium text-gray-500"> </span>
<span className="text-xs text-gray-400 bg-gray-100 px-3 py-1 rounded-full"> </span>
</div>
</TableCell>
</TableRow>
) : (
@ -151,11 +159,11 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableRow
key={`row-${index}`}
className={cn(
"h-10 cursor-pointer border-b leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/30",
"h-12 cursor-pointer border-b border-gray-100/60 leading-none transition-all duration-200",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/20 hover:shadow-sm",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gradient-to-r from-slate-50/30 to-gray-50/20",
)}
style={{ minHeight: "40px", height: "40px", lineHeight: "1" }}
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column, colIndex) => {
@ -177,15 +185,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<TableCell
key={`cell-${column.columnName}`}
className={cn(
"h-10 px-4 py-2 align-middle text-sm whitespace-nowrap",
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap text-slate-600 transition-all duration-200",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" && "sticky z-10 border-r bg-white",
column.fixed === "right" && "sticky z-10 border-l bg-white",
column.fixed === "left" && "sticky z-10 border-r border-gray-200/60 bg-white/90 backdrop-blur-sm",
column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-white/90 backdrop-blur-sm",
)}
style={{
minHeight: "40px",
height: "40px",
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
width: getColumnWidth(column),
boxSizing: "border-box",

View File

@ -50,9 +50,10 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
const zoneStyle: React.CSSProperties = {
position: "relative",
// 구역 경계 시각화 - 항상 표시
border: "1px solid #e2e8f0",
borderRadius: "6px",
backgroundColor: "rgba(248, 250, 252, 0.5)",
border: "1px solid rgba(226, 232, 240, 0.6)",
borderRadius: "12px",
backgroundColor: "rgba(248, 250, 252, 0.3)",
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
transition: "all 0.2s ease",
...this.getZoneStyle(zone),
...additionalProps.style,
@ -62,19 +63,21 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
if (isDesignMode) {
// 🎯 컴포넌트가 있는 존은 테두리 제거 (컴포넌트 자체 테두리와 충돌 방지)
if (zoneChildren.length === 0) {
zoneStyle.border = "2px dashed #cbd5e1";
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
zoneStyle.border = "2px dashed rgba(203, 213, 225, 0.6)";
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.5)";
zoneStyle.borderRadius = "12px";
} else {
// 컴포넌트가 있는 존은 미묘한 배경만
zoneStyle.border = "1px solid transparent";
zoneStyle.backgroundColor = "rgba(248, 250, 252, 0.3)";
zoneStyle.border = "1px solid rgba(226, 232, 240, 0.3)";
zoneStyle.backgroundColor = "rgba(248, 250, 252, 0.2)";
zoneStyle.borderRadius = "12px";
}
}
// 호버 효과를 위한 추가 스타일
const dropZoneStyle: React.CSSProperties = {
minHeight: isDesignMode ? "60px" : "40px",
borderRadius: "4px",
minHeight: isDesignMode ? "80px" : "50px",
borderRadius: "12px",
display: "flex",
flexDirection: "column",
alignItems: zoneChildren.length === 0 ? "center" : "stretch",

View File

@ -0,0 +1,123 @@
/**
*
*
*/
import { ComponentData } from "@/types/screen";
/**
*
*
* :
* - 레거시: type="file"
* - 레거시: type="widget" + widgetType="file"
* - 신규: type="component" + widgetType="file"
* - 신규: type="component" + componentType="file-upload"
* - 신규: type="component" + componentConfig.webType="file"
*/
export const isFileComponent = (component: ComponentData): boolean => {
return component.type === "file" ||
(component.type === "widget" && (component as any).widgetType === "file") ||
(component.type === "component" &&
((component as any).widgetType === "file" || // ✅ ScreenDesigner에서 설정됨
(component as any).componentType === "file-upload" || // ✅ ComponentRegistry ID
(component as any).componentConfig?.webType === "file")); // ✅ componentConfig 내부
};
/**
*
*/
export const isButtonComponent = (component: ComponentData): boolean => {
return component.type === "button" ||
(component.type === "widget" && (component as any).widgetType === "button") ||
(component.type === "component" &&
((component as any).webType === "button" ||
(component as any).componentType === "button"));
};
/**
*
*/
export const isDataTableComponent = (component: ComponentData): boolean => {
return component.type === "datatable" ||
(component.type === "component" &&
((component as any).componentType === "datatable" ||
(component as any).componentType === "data-table"));
};
/**
*
*/
export const isWidgetComponent = (component: ComponentData): boolean => {
return component.type === "widget";
};
/**
*
*/
export const getComponentWebType = (component: ComponentData): string | undefined => {
// 파일 컴포넌트는 무조건 "file" 웹타입 반환
if (isFileComponent(component)) {
console.log(`🎯 파일 컴포넌트 감지 → webType: "file" 반환`, {
componentId: component.id,
componentType: component.type,
widgetType: (component as any).widgetType,
componentConfig: (component as any).componentConfig
});
return "file";
}
if (component.type === "widget") {
return (component as any).widgetType;
}
if (component.type === "component") {
return (component as any).widgetType || (component as any).componentConfig?.webType;
}
return component.type;
};
/**
* ( )
*/
export const getComponentType = (component: ComponentData): string => {
if (component.type === "component") {
return (component as any).componentType || (component as any).webType || "unknown";
}
return component.type;
};
/**
*
*/
export const isInputComponent = (component: ComponentData): boolean => {
const inputTypes = ["text", "number", "email", "password", "tel", "url", "search",
"textarea", "select", "checkbox", "radio", "date", "time",
"datetime-local", "file", "code", "entity"];
const webType = getComponentWebType(component);
return webType ? inputTypes.includes(webType) : false;
};
/**
*
*/
export const isDisplayComponent = (component: ComponentData): boolean => {
const displayTypes = ["label", "text", "image", "video", "chart", "table", "datatable"];
const webType = getComponentWebType(component);
return webType ? displayTypes.includes(webType) : false;
};
/**
*
*/
export const getComponentFieldName = (component: ComponentData): string => {
return (component as any).columnName || component.id;
};
/**
*
*/
export const getComponentLabel = (component: ComponentData): string => {
return (component as any).label || (component as any).title || component.id;
};

View File

@ -47,7 +47,9 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"docx-preview": "^0.3.6",
"lucide-react": "^0.525.0",
"mammoth": "^1.11.0",
"next": "15.4.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
@ -55,8 +57,10 @@
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-window": "^2.1.0",
"sheetjs-style": "^0.15.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"xlsx": "^0.18.5",
"zod": "^4.1.5"
},
"devDependencies": {

View File

@ -114,6 +114,9 @@ export interface FileComponent extends BaseComponent {
type: "file";
fileConfig: FileTypeConfig;
uploadedFiles?: UploadedFile[];
columnName?: string;
tableName?: string;
lastFileUpdate?: number;
}
/**
@ -193,12 +196,21 @@ export interface TextTypeConfig {
*
*/
export interface FileTypeConfig {
accept?: string;
accept?: string[];
multiple?: boolean;
maxSize?: number; // bytes
maxSize?: number; // MB
maxFiles?: number;
preview?: boolean;
showPreview?: boolean;
showProgress?: boolean;
docType?: string;
docTypeName?: string;
dragDropText?: string;
uploadButtonText?: string;
autoUpload?: boolean;
chunkedUpload?: boolean;
linkedTable?: string;
linkedField?: string;
autoLink?: boolean;
companyCode?: CompanyCode;
}
@ -272,6 +284,8 @@ export interface UploadedFile {
filePath: string;
docType?: string;
docTypeName?: string;
targetObjid: string;
parentTargetObjid?: string;
writer?: string;
regdate?: string;
status?: "uploading" | "completed" | "error";

1
test-upload.txt Normal file
View File

@ -0,0 +1 @@
테스트 파일입니다.