Compare commits
24 Commits
4c20d93c87
...
c973cb674d
| Author | SHA1 | Date |
|---|---|---|
|
|
c973cb674d | |
|
|
f39d86a269 | |
|
|
51d9c45d9e | |
|
|
38f0f865df | |
|
|
6da8d14845 | |
|
|
318436475a | |
|
|
142f6a1a90 | |
|
|
0b787b4c4c | |
|
|
d0d37d9e29 | |
|
|
6e3d5b40d2 | |
|
|
66395c9cc5 | |
|
|
da429e7f24 | |
|
|
78d49ee936 | |
|
|
126b3e1175 | |
|
|
55f4c7fa26 | |
|
|
e74deb7c34 | |
|
|
6e8f529cd3 | |
|
|
467c5598ab | |
|
|
808a317ed0 | |
|
|
5a5af8d258 | |
|
|
a5bf6601a0 | |
|
|
6ce5fc84a8 | |
|
|
bff7416cd1 | |
|
|
e0143e9cba |
|
|
@ -280,7 +280,6 @@ backend-node/uploads/
|
||||||
uploads/
|
uploads/
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
*.png
|
|
||||||
*.gif
|
*.gif
|
||||||
*.pdf
|
*.pdf
|
||||||
*.doc
|
*.doc
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,20 @@ import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
const app = express();
|
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(compression());
|
||||||
app.use(express.json({ limit: "10mb" }));
|
app.use(express.json({ limit: "10mb" }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||||
|
|
@ -97,7 +110,7 @@ app.use(
|
||||||
// Rate Limiting (개발 환경에서는 완화)
|
// Rate Limiting (개발 환경에서는 완화)
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: 1 * 60 * 1000, // 1분
|
windowMs: 1 * 60 * 1000, // 1분
|
||||||
max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
|
max: config.nodeEnv === "development" ? 10000 : 10000, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
|
||||||
message: {
|
message: {
|
||||||
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,26 @@ export class CommonCodeController {
|
||||||
|
|
||||||
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||||
const transformedData = result.data.map((code: any) => ({
|
const transformedData = result.data.map((code: any) => ({
|
||||||
|
// 새로운 필드명 (카멜케이스)
|
||||||
codeValue: code.code_value,
|
codeValue: code.code_value,
|
||||||
codeName: code.code_name,
|
codeName: code.code_name,
|
||||||
|
codeNameEng: code.code_name_eng,
|
||||||
description: code.description,
|
description: code.description,
|
||||||
sortOrder: code.sort_order,
|
sortOrder: code.sort_order,
|
||||||
isActive: code.is_active === "Y",
|
isActive: code.is_active,
|
||||||
useYn: code.is_active,
|
useYn: code.is_active,
|
||||||
|
|
||||||
|
// 기존 필드명도 유지 (하위 호환성)
|
||||||
|
code_category: code.code_category,
|
||||||
|
code_value: code.code_value,
|
||||||
|
code_name: code.code_name,
|
||||||
|
code_name_eng: code.code_name_eng,
|
||||||
|
sort_order: code.sort_order,
|
||||||
|
is_active: code.is_active,
|
||||||
|
created_date: code.created_date,
|
||||||
|
created_by: code.created_by,
|
||||||
|
updated_date: code.updated_date,
|
||||||
|
updated_by: code.updated_by,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import { generateUUID } from "../utils/generateId";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
||||||
|
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
||||||
|
|
||||||
// 업로드 디렉토리 설정 (회사별로 분리)
|
// 업로드 디렉토리 설정 (회사별로 분리)
|
||||||
const baseUploadDir = path.join(process.cwd(), "uploads");
|
const baseUploadDir = path.join(process.cwd(), "uploads");
|
||||||
|
|
||||||
|
|
@ -266,8 +269,6 @@ export const uploadFiles = async (
|
||||||
|
|
||||||
// 회사코드가 *인 경우 company_*로 변환
|
// 회사코드가 *인 경우 company_*로 변환
|
||||||
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
|
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
|
||||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
|
||||||
const fullFilePath = `/uploads${relativePath}`;
|
|
||||||
|
|
||||||
// 임시 파일을 최종 위치로 이동
|
// 임시 파일을 최종 위치로 이동
|
||||||
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
||||||
|
|
@ -277,6 +278,10 @@ export const uploadFiles = async (
|
||||||
// 파일 이동
|
// 파일 이동
|
||||||
fs.renameSync(tempFilePath, finalFilePath);
|
fs.renameSync(tempFilePath, finalFilePath);
|
||||||
|
|
||||||
|
// DB에 저장할 경로 (실제 파일 위치와 일치)
|
||||||
|
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
||||||
|
const fullFilePath = `/uploads${relativePath}`;
|
||||||
|
|
||||||
// attach_file_info 테이블에 저장
|
// attach_file_info 테이블에 저장
|
||||||
const fileRecord = await prisma.attach_file_info.create({
|
const fileRecord = await prisma.attach_file_info.create({
|
||||||
data: {
|
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 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!;
|
const fileName = fileRecord.saved_file_name!;
|
||||||
|
|
||||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||||
|
|
@ -527,6 +665,17 @@ export const previewFile = async (
|
||||||
);
|
);
|
||||||
const filePath = path.join(companyUploadDir, fileName);
|
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)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.error("❌ 파일 없음:", filePath);
|
console.error("❌ 파일 없음:", filePath);
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -615,7 +764,13 @@ export const downloadFile = async (
|
||||||
|
|
||||||
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
// 파일 경로에서 회사코드와 날짜 폴더 추출 (예: /uploads/company_*/2025/09/05/timestamp_filename.ext)
|
||||||
const filePathParts = fileRecord.file_path!.split("/");
|
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!;
|
const fileName = fileRecord.saved_file_name!;
|
||||||
|
|
||||||
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||||
|
|
@ -631,6 +786,17 @@ export const downloadFile = async (
|
||||||
);
|
);
|
||||||
const filePath = path.join(companyUploadDir, fileName);
|
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)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.error("❌ 파일 없음:", filePath);
|
console.error("❌ 파일 없음:", filePath);
|
||||||
res.status(404).json({
|
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
|
// Multer 미들웨어 export
|
||||||
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
export const uploadMiddleware = upload.array("files", 10); // 최대 10개 파일
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,26 @@ import {
|
||||||
uploadFiles,
|
uploadFiles,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
getFileList,
|
getFileList,
|
||||||
|
getComponentFiles,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
previewFile,
|
previewFile,
|
||||||
getLinkedFiles,
|
getLinkedFiles,
|
||||||
uploadMiddleware,
|
uploadMiddleware,
|
||||||
|
generateTempToken,
|
||||||
|
getFileByToken,
|
||||||
} from "../controllers/fileController";
|
} from "../controllers/fileController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// 공개 접근 라우트 (인증 불필요)
|
||||||
|
/**
|
||||||
|
* @route GET /api/files/public/:token
|
||||||
|
* @desc 임시 토큰으로 파일 접근 (Google Docs Viewer용)
|
||||||
|
* @access Public
|
||||||
|
*/
|
||||||
|
router.get("/public/:token", getFileByToken);
|
||||||
|
|
||||||
// 모든 파일 API는 인증 필요
|
// 모든 파일 API는 인증 필요
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
|
@ -30,6 +41,14 @@ router.post("/upload", uploadMiddleware, uploadFiles);
|
||||||
*/
|
*/
|
||||||
router.get("/", getFileList);
|
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
|
* @route GET /api/files/linked/:tableName/:recordId
|
||||||
* @desc 테이블 연결된 파일 조회
|
* @desc 테이블 연결된 파일 조회
|
||||||
|
|
@ -58,4 +77,11 @@ router.get("/preview/:objid", previewFile);
|
||||||
*/
|
*/
|
||||||
router.get("/download/:objid", downloadFile);
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,15 @@ import {
|
||||||
import { MultiConnectionQueryService } from "./multiConnectionQueryService";
|
import { MultiConnectionQueryService } from "./multiConnectionQueryService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
export interface EnhancedControlAction extends ControlAction {
|
export interface EnhancedControlAction
|
||||||
// 🆕 기본 ControlAction 속성들 (상속됨)
|
extends Omit<ControlAction, "id" | "conditions" | "fieldMappings"> {
|
||||||
id?: number;
|
// 🆕 기본 ControlAction 속성들 (일부 재정의)
|
||||||
actionType?: string;
|
id: string; // ControlAction과 호환성을 위해 string 타입 유지
|
||||||
fromTable: string;
|
fromTable: string;
|
||||||
|
|
||||||
// 🆕 추가 속성들
|
// 🆕 추가 속성들 (선택적으로 재정의)
|
||||||
conditions?: ControlCondition[];
|
conditions: ControlCondition[]; // 필수 속성으로 변경
|
||||||
fieldMappings?: any[];
|
fieldMappings: any[]; // 필수 속성으로 변경
|
||||||
|
|
||||||
// 🆕 UPDATE 액션 관련 필드
|
// 🆕 UPDATE 액션 관련 필드
|
||||||
updateConditions?: UpdateCondition[];
|
updateConditions?: UpdateCondition[];
|
||||||
|
|
@ -166,16 +166,16 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
let actionResult: any;
|
let actionResult: any;
|
||||||
|
|
||||||
// 커넥션 ID 추출
|
// 커넥션 ID 추출
|
||||||
const sourceConnectionId = enhancedAction.fromConnection?.connectionId || enhancedAction.fromConnection?.id || 0;
|
const sourceConnectionId = enhancedAction.fromConnection?.id || 0;
|
||||||
const targetConnectionId = enhancedAction.toConnection?.connectionId || enhancedAction.toConnection?.id || 0;
|
const targetConnectionId = enhancedAction.toConnection?.id || 0;
|
||||||
|
|
||||||
switch (enhancedAction.actionType) {
|
switch (enhancedAction.actionType) {
|
||||||
case "insert":
|
case "insert":
|
||||||
actionResult = await this.executeMultiConnectionInsert(
|
actionResult = await this.executeEnhancedMultiConnectionInsert(
|
||||||
enhancedAction,
|
enhancedAction,
|
||||||
sourceData,
|
sourceData,
|
||||||
enhancedAction.fromTable,
|
enhancedAction.fromTable,
|
||||||
enhancedAction.targetTable,
|
enhancedAction.targetTable || enhancedAction.fromTable,
|
||||||
sourceConnectionId,
|
sourceConnectionId,
|
||||||
targetConnectionId,
|
targetConnectionId,
|
||||||
null
|
null
|
||||||
|
|
@ -183,11 +183,11 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "update":
|
case "update":
|
||||||
actionResult = await this.executeMultiConnectionUpdate(
|
actionResult = await this.executeEnhancedMultiConnectionUpdate(
|
||||||
enhancedAction,
|
enhancedAction,
|
||||||
sourceData,
|
sourceData,
|
||||||
enhancedAction.fromTable,
|
enhancedAction.fromTable,
|
||||||
enhancedAction.targetTable,
|
enhancedAction.targetTable || enhancedAction.fromTable,
|
||||||
sourceConnectionId,
|
sourceConnectionId,
|
||||||
targetConnectionId,
|
targetConnectionId,
|
||||||
null
|
null
|
||||||
|
|
@ -195,11 +195,11 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "delete":
|
case "delete":
|
||||||
actionResult = await this.executeMultiConnectionDelete(
|
actionResult = await this.executeEnhancedMultiConnectionDelete(
|
||||||
enhancedAction,
|
enhancedAction,
|
||||||
sourceData,
|
sourceData,
|
||||||
enhancedAction.fromTable,
|
enhancedAction.fromTable,
|
||||||
enhancedAction.targetTable,
|
enhancedAction.targetTable || enhancedAction.fromTable,
|
||||||
sourceConnectionId,
|
sourceConnectionId,
|
||||||
targetConnectionId,
|
targetConnectionId,
|
||||||
null
|
null
|
||||||
|
|
@ -247,8 +247,8 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
/**
|
/**
|
||||||
* 🆕 다중 커넥션 INSERT 실행
|
* 🆕 다중 커넥션 INSERT 실행
|
||||||
*/
|
*/
|
||||||
async executeMultiConnectionInsert(
|
async executeEnhancedMultiConnectionInsert(
|
||||||
action: EnhancedControlAction,
|
action: ControlAction,
|
||||||
sourceData: Record<string, any>,
|
sourceData: Record<string, any>,
|
||||||
sourceTable: string,
|
sourceTable: string,
|
||||||
targetTable: string,
|
targetTable: string,
|
||||||
|
|
@ -257,16 +257,17 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
multiConnService: any
|
multiConnService: any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
logger.info(`다중 커넥션 INSERT 실행: action=${action.action}`);
|
const enhancedAction = action as EnhancedControlAction;
|
||||||
|
logger.info(`다중 커넥션 INSERT 실행: action=${action.id}`);
|
||||||
|
|
||||||
// 커넥션 ID 결정
|
// 커넥션 ID 결정
|
||||||
const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0;
|
const fromConnId = fromConnectionId || action.fromConnection?.id || 0;
|
||||||
const toConnId = toConnectionId || action.toConnection?.connectionId || 0;
|
const toConnId = toConnectionId || action.toConnection?.id || 0;
|
||||||
|
|
||||||
// FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우)
|
// FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우)
|
||||||
let fromData = sourceData;
|
let fromData = sourceData;
|
||||||
if (
|
if (
|
||||||
action.fromTable &&
|
enhancedAction.fromTable &&
|
||||||
action.conditions &&
|
action.conditions &&
|
||||||
action.conditions.length > 0
|
action.conditions.length > 0
|
||||||
) {
|
) {
|
||||||
|
|
@ -277,7 +278,7 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
const fromResults =
|
const fromResults =
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
await this.multiConnectionService.fetchDataFromConnection(
|
||||||
fromConnId,
|
fromConnId,
|
||||||
action.fromTable,
|
enhancedAction.fromTable,
|
||||||
queryConditions
|
queryConditions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -302,7 +303,7 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
const insertResult =
|
const insertResult =
|
||||||
await this.multiConnectionService.insertDataToConnection(
|
await this.multiConnectionService.insertDataToConnection(
|
||||||
toConnId,
|
toConnId,
|
||||||
action.targetTable,
|
action.targetTable || enhancedAction.fromTable,
|
||||||
mappedData
|
mappedData
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -317,8 +318,8 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
/**
|
/**
|
||||||
* 🆕 다중 커넥션 UPDATE 실행
|
* 🆕 다중 커넥션 UPDATE 실행
|
||||||
*/
|
*/
|
||||||
async executeMultiConnectionUpdate(
|
async executeEnhancedMultiConnectionUpdate(
|
||||||
action: EnhancedControlAction,
|
action: ControlAction,
|
||||||
sourceData: Record<string, any>,
|
sourceData: Record<string, any>,
|
||||||
sourceTable: string,
|
sourceTable: string,
|
||||||
targetTable: string,
|
targetTable: string,
|
||||||
|
|
@ -327,26 +328,30 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
multiConnService: any
|
multiConnService: any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
logger.info(`다중 커넥션 UPDATE 실행: action=${action.action}`);
|
const enhancedAction = action as EnhancedControlAction;
|
||||||
|
logger.info(`다중 커넥션 UPDATE 실행: action=${action.id}`);
|
||||||
|
|
||||||
// 커넥션 ID 결정
|
// 커넥션 ID 결정
|
||||||
const fromConnId = fromConnectionId || action.fromConnection?.connectionId || 0;
|
const fromConnId = fromConnectionId || action.fromConnection?.id || 0;
|
||||||
const toConnId = toConnectionId || action.toConnection?.connectionId || 0;
|
const toConnId = toConnectionId || action.toConnection?.id || 0;
|
||||||
|
|
||||||
// UPDATE 조건 확인
|
// UPDATE 조건 확인
|
||||||
if (!action.updateConditions || action.updateConditions.length === 0) {
|
if (
|
||||||
|
!enhancedAction.updateConditions ||
|
||||||
|
enhancedAction.updateConditions.length === 0
|
||||||
|
) {
|
||||||
throw new Error("UPDATE 작업에는 업데이트 조건이 필요합니다.");
|
throw new Error("UPDATE 작업에는 업데이트 조건이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// FROM 테이블에서 업데이트 조건 확인
|
// FROM 테이블에서 업데이트 조건 확인
|
||||||
const updateConditions = this.buildUpdateConditions(
|
const updateConditions = this.buildUpdateConditions(
|
||||||
action.updateConditions,
|
enhancedAction.updateConditions,
|
||||||
sourceData
|
sourceData
|
||||||
);
|
);
|
||||||
const fromResults =
|
const fromResults =
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
await this.multiConnectionService.fetchDataFromConnection(
|
||||||
fromConnId,
|
fromConnId,
|
||||||
action.fromTable || action.targetTable,
|
enhancedAction.fromTable || action.targetTable || "default_table",
|
||||||
updateConditions
|
updateConditions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -360,13 +365,13 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
|
|
||||||
// 업데이트 필드 매핑 적용
|
// 업데이트 필드 매핑 적용
|
||||||
const updateData = this.applyUpdateFieldMappings(
|
const updateData = this.applyUpdateFieldMappings(
|
||||||
action.updateFields || [],
|
enhancedAction.updateFields || [],
|
||||||
fromResults[0]
|
fromResults[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
// WHERE 조건 구성 (TO 테이블 대상)
|
// WHERE 조건 구성 (TO 테이블 대상)
|
||||||
const whereConditions = this.buildWhereConditions(
|
const whereConditions = this.buildWhereConditions(
|
||||||
action.updateFields || [],
|
enhancedAction.updateFields || [],
|
||||||
fromResults[0]
|
fromResults[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -374,7 +379,7 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
const updateResult =
|
const updateResult =
|
||||||
await this.multiConnectionService.updateDataToConnection(
|
await this.multiConnectionService.updateDataToConnection(
|
||||||
toConnId,
|
toConnId,
|
||||||
action.targetTable,
|
action.targetTable || enhancedAction.fromTable,
|
||||||
updateData,
|
updateData,
|
||||||
whereConditions
|
whereConditions
|
||||||
);
|
);
|
||||||
|
|
@ -390,8 +395,8 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
/**
|
/**
|
||||||
* 🆕 다중 커넥션 DELETE 실행
|
* 🆕 다중 커넥션 DELETE 실행
|
||||||
*/
|
*/
|
||||||
async executeMultiConnectionDelete(
|
async executeEnhancedMultiConnectionDelete(
|
||||||
action: EnhancedControlAction,
|
action: ControlAction,
|
||||||
sourceData: Record<string, any>,
|
sourceData: Record<string, any>,
|
||||||
sourceTable: string,
|
sourceTable: string,
|
||||||
targetTable: string,
|
targetTable: string,
|
||||||
|
|
@ -400,28 +405,30 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
multiConnService: any
|
multiConnService: any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
logger.info(`다중 커넥션 DELETE 실행: action=${action.action}`);
|
const enhancedAction = action as EnhancedControlAction;
|
||||||
|
logger.info(`다중 커넥션 DELETE 실행: action=${action.id}`);
|
||||||
|
|
||||||
// 커넥션 ID 결정
|
// 커넥션 ID 결정
|
||||||
const fromConnId =
|
const fromConnId = fromConnectionId || action.fromConnection?.id || 0;
|
||||||
fromConnectionId || action.fromConnection?.connectionId || 0;
|
const toConnId = toConnectionId || action.toConnection?.id || 0;
|
||||||
const toConnId =
|
|
||||||
toConnectionId || action.toConnection?.connectionId || 0;
|
|
||||||
|
|
||||||
// DELETE 조건 확인
|
// DELETE 조건 확인
|
||||||
if (!action.deleteConditions || action.deleteConditions.length === 0) {
|
if (
|
||||||
|
!enhancedAction.deleteConditions ||
|
||||||
|
enhancedAction.deleteConditions.length === 0
|
||||||
|
) {
|
||||||
throw new Error("DELETE 작업에는 삭제 조건이 필요합니다.");
|
throw new Error("DELETE 작업에는 삭제 조건이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// FROM 테이블에서 삭제 트리거 조건 확인
|
// FROM 테이블에서 삭제 트리거 조건 확인
|
||||||
const deleteConditions = this.buildDeleteConditions(
|
const deleteConditions = this.buildDeleteConditions(
|
||||||
action.deleteConditions,
|
enhancedAction.deleteConditions,
|
||||||
sourceData
|
sourceData
|
||||||
);
|
);
|
||||||
const fromResults =
|
const fromResults =
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
await this.multiConnectionService.fetchDataFromConnection(
|
||||||
fromConnId,
|
fromConnId,
|
||||||
action.fromTable || action.targetTable,
|
enhancedAction.fromTable || action.targetTable || "default_table",
|
||||||
deleteConditions
|
deleteConditions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -432,7 +439,7 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
|
|
||||||
// WHERE 조건 구성 (TO 테이블 대상)
|
// WHERE 조건 구성 (TO 테이블 대상)
|
||||||
const whereConditions = this.buildDeleteWhereConditions(
|
const whereConditions = this.buildDeleteWhereConditions(
|
||||||
action.deleteWhereConditions || [],
|
enhancedAction.deleteWhereConditions || [],
|
||||||
fromResults[0]
|
fromResults[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -441,14 +448,14 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 안전장치 적용
|
// 안전장치 적용
|
||||||
const maxDeleteCount = action.maxDeleteCount || 100;
|
const maxDeleteCount = enhancedAction.maxDeleteCount || 100;
|
||||||
|
|
||||||
// Dry Run 실행 (선택사항)
|
// Dry Run 실행 (선택사항)
|
||||||
if (action.dryRunFirst) {
|
if (enhancedAction.dryRunFirst) {
|
||||||
const countResult =
|
const countResult =
|
||||||
await this.multiConnectionService.fetchDataFromConnection(
|
await this.multiConnectionService.fetchDataFromConnection(
|
||||||
toConnId,
|
toConnId,
|
||||||
action.targetTable,
|
action.targetTable || enhancedAction.fromTable,
|
||||||
whereConditions
|
whereConditions
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -465,13 +472,13 @@ export class EnhancedDataflowControlService extends DataflowControlService {
|
||||||
const deleteResult =
|
const deleteResult =
|
||||||
await this.multiConnectionService.deleteDataFromConnection(
|
await this.multiConnectionService.deleteDataFromConnection(
|
||||||
toConnId,
|
toConnId,
|
||||||
action.targetTable,
|
action.targetTable || enhancedAction.fromTable,
|
||||||
whereConditions,
|
whereConditions,
|
||||||
maxDeleteCount
|
maxDeleteCount
|
||||||
);
|
);
|
||||||
|
|
||||||
// 삭제 로그 기록 (선택사항)
|
// 삭제 로그 기록 (선택사항)
|
||||||
if (action.logAllDeletes) {
|
if (enhancedAction.logAllDeletes) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`삭제 실행 로그: ${JSON.stringify({
|
`삭제 실행 로그: ${JSON.stringify({
|
||||||
action: action.id,
|
action: action.id,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 메인 페이지
|
* 관리자 메인 페이지
|
||||||
|
|
@ -199,6 +200,16 @@ export default function AdminPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
import { EditModal } from "@/components/screen/EditModal";
|
import { EditModal } from "@/components/screen/EditModal";
|
||||||
|
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
||||||
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
|
// import { ResponsiveScreenContainer } from "@/components/screen/ResponsiveScreenContainer"; // 컨테이너 제거
|
||||||
|
|
||||||
export default function ScreenViewPage() {
|
export default function ScreenViewPage() {
|
||||||
|
|
@ -116,10 +117,10 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
<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">
|
<div className="text-center bg-white rounded-xl border border-gray-200/60 shadow-lg p-8">
|
||||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-600" />
|
||||||
<p className="mt-2 text-gray-600">화면을 불러오는 중...</p>
|
<p className="mt-4 text-gray-700 font-medium">화면을 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -127,14 +128,14 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
if (error || !screen) {
|
if (error || !screen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-white">
|
<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">
|
<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-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
<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-2xl">⚠️</span>
|
<span className="text-3xl">⚠️</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면을 찾을 수 없습니다</h2>
|
<h2 className="mb-3 text-xl font-bold text-gray-900">화면을 찾을 수 없습니다</h2>
|
||||||
<p className="mb-4 text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
<p className="mb-6 text-gray-600 leading-relaxed">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||||
<Button onClick={() => router.back()} variant="outline">
|
<Button onClick={() => router.back()} variant="outline" className="rounded-lg">
|
||||||
이전으로 돌아가기
|
이전으로 돌아가기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,17 +148,17 @@ export default function ScreenViewPage() {
|
||||||
const screenHeight = layout?.screenResolution?.height || 800;
|
const screenHeight = layout?.screenResolution?.height || 800;
|
||||||
|
|
||||||
return (
|
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 ? (
|
{layout && layout.components.length > 0 ? (
|
||||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||||
<div
|
<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={{
|
style={{
|
||||||
width: `${screenWidth}px`,
|
width: `${screenWidth}px`,
|
||||||
height: `${screenHeight}px`,
|
height: `${screenHeight}px`,
|
||||||
minWidth: `${screenWidth}px`,
|
minWidth: `${screenWidth}px`,
|
||||||
minHeight: `${screenHeight}px`,
|
minHeight: `${screenHeight}px`,
|
||||||
margin: "0", // mx-auto 제거하여 사이드바 오프셋 방지
|
margin: "0 auto 40px auto", // 하단 여백 추가
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{layout.components
|
{layout.components
|
||||||
|
|
@ -177,15 +178,16 @@ export default function ScreenViewPage() {
|
||||||
width: `${component.size.width}px`,
|
width: `${component.size.width}px`,
|
||||||
height: `${component.size.height}px`,
|
height: `${component.size.height}px`,
|
||||||
zIndex: component.position.z || 1,
|
zIndex: component.position.z || 1,
|
||||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
|
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.05)",
|
||||||
border: (component as any).border || "2px dashed #3b82f6",
|
border: (component as any).border || "1px solid rgba(59, 130, 246, 0.2)",
|
||||||
borderRadius: (component as any).borderRadius || "8px",
|
borderRadius: (component as any).borderRadius || "12px",
|
||||||
padding: "16px",
|
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 && (
|
{(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
|
<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}
|
config={component.webTypeConfig}
|
||||||
props={{
|
props={{
|
||||||
component: component,
|
component: component,
|
||||||
|
|
@ -338,13 +352,13 @@ export default function ScreenViewPage() {
|
||||||
},
|
},
|
||||||
onFormDataChange: (fieldName, value) => {
|
onFormDataChange: (fieldName, value) => {
|
||||||
console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`);
|
console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||||
console.log(`📋 현재 formData:`, formData);
|
console.log("📋 현재 formData:", formData);
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const newFormData = {
|
const newFormData = {
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
};
|
};
|
||||||
console.log(`📝 업데이트된 formData:`, newFormData);
|
console.log("📝 업데이트된 formData:", newFormData);
|
||||||
return newFormData;
|
return newFormData;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,9 @@ export default function RootLayout({
|
||||||
<div id="root" className="h-full">
|
<div id="root" className="h-full">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<RegistryProvider>{children}</RegistryProvider>
|
<RegistryProvider>{children}</RegistryProvider>
|
||||||
|
<Toaster position="top-right" richColors />
|
||||||
|
<ScreenModal />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
<Toaster position="top-right" richColors />
|
|
||||||
<ScreenModal />
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -14,7 +14,7 @@ import { ValidationMessage } from "@/components/common/ValidationMessage";
|
||||||
import { useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories";
|
import { useCreateCategory, useUpdateCategory } from "@/hooks/queries/useCategories";
|
||||||
import type { CodeCategory } from "@/types/commonCode";
|
import type { CodeCategory } from "@/types/commonCode";
|
||||||
import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation";
|
import { useCheckCategoryDuplicate } from "@/hooks/queries/useValidation";
|
||||||
import { useFormValidation } from "@/hooks/useFormValidation";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
createCategorySchema,
|
createCategorySchema,
|
||||||
updateCategorySchema,
|
updateCategorySchema,
|
||||||
|
|
@ -41,45 +41,6 @@ export function CodeCategoryFormModal({
|
||||||
const isEditing = !!editingCategoryCode;
|
const isEditing = !!editingCategoryCode;
|
||||||
const editingCategory = categories.find((c) => c.category_code === editingCategoryCode);
|
const editingCategory = categories.find((c) => c.category_code === editingCategoryCode);
|
||||||
|
|
||||||
// 검증 상태 관리
|
|
||||||
const formValidation = useFormValidation({
|
|
||||||
fields: ["categoryCode", "categoryName", "categoryNameEng", "description"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 중복 검사 훅들
|
|
||||||
const categoryCodeCheck = useCheckCategoryDuplicate(
|
|
||||||
"categoryCode",
|
|
||||||
formValidation.getFieldValue("categoryCode"),
|
|
||||||
isEditing ? editingCategoryCode : undefined,
|
|
||||||
formValidation.isFieldValidated("categoryCode"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoryNameCheck = useCheckCategoryDuplicate(
|
|
||||||
"categoryName",
|
|
||||||
formValidation.getFieldValue("categoryName"),
|
|
||||||
isEditing ? editingCategoryCode : undefined,
|
|
||||||
formValidation.isFieldValidated("categoryName"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoryNameEngCheck = useCheckCategoryDuplicate(
|
|
||||||
"categoryNameEng",
|
|
||||||
formValidation.getFieldValue("categoryNameEng"),
|
|
||||||
isEditing ? editingCategoryCode : undefined,
|
|
||||||
formValidation.isFieldValidated("categoryNameEng"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
|
|
||||||
const hasDuplicateErrors =
|
|
||||||
(!isEditing && categoryCodeCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryCode")) ||
|
|
||||||
(categoryNameCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryName")) ||
|
|
||||||
(categoryNameEngCheck.data?.isDuplicate && formValidation.isFieldValidated("categoryNameEng"));
|
|
||||||
|
|
||||||
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
|
|
||||||
const isDuplicateChecking =
|
|
||||||
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
|
|
||||||
|
|
||||||
// 필수 필드들이 모두 검증되었는지 확인 (생성 시에만 적용)
|
|
||||||
|
|
||||||
// 생성과 수정을 위한 별도 폼 설정
|
// 생성과 수정을 위한 별도 폼 설정
|
||||||
const createForm = useForm<CreateCategoryData>({
|
const createForm = useForm<CreateCategoryData>({
|
||||||
resolver: zodResolver(createCategorySchema),
|
resolver: zodResolver(createCategorySchema),
|
||||||
|
|
@ -105,11 +66,54 @@ export function CodeCategoryFormModal({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 필드 검증 상태 관리
|
||||||
|
const [validatedFields, setValidatedFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 필드 검증 처리 함수
|
||||||
|
const handleFieldBlur = (fieldName: string) => {
|
||||||
|
setValidatedFields((prev) => new Set(prev).add(fieldName));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 중복 검사 훅들
|
||||||
|
const categoryCodeCheck = useCheckCategoryDuplicate(
|
||||||
|
"categoryCode",
|
||||||
|
isEditing ? "" : createForm.watch("categoryCode"),
|
||||||
|
isEditing ? editingCategoryCode : undefined,
|
||||||
|
validatedFields.has("categoryCode"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryNameCheck = useCheckCategoryDuplicate(
|
||||||
|
"categoryName",
|
||||||
|
isEditing ? updateForm.watch("categoryName") : createForm.watch("categoryName"),
|
||||||
|
isEditing ? editingCategoryCode : undefined,
|
||||||
|
validatedFields.has("categoryName"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryNameEngCheck = useCheckCategoryDuplicate(
|
||||||
|
"categoryNameEng",
|
||||||
|
isEditing ? updateForm.watch("categoryNameEng") : createForm.watch("categoryNameEng"),
|
||||||
|
isEditing ? editingCategoryCode : undefined,
|
||||||
|
validatedFields.has("categoryNameEng"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 중복 검사 결과 확인 (수정 시에는 카테고리 코드 검사 제외)
|
||||||
|
const hasDuplicateErrors =
|
||||||
|
(!isEditing && categoryCodeCheck.data?.isDuplicate && validatedFields.has("categoryCode")) ||
|
||||||
|
(categoryNameCheck.data?.isDuplicate && validatedFields.has("categoryName")) ||
|
||||||
|
(categoryNameEngCheck.data?.isDuplicate && validatedFields.has("categoryNameEng"));
|
||||||
|
|
||||||
|
// 중복 검사 로딩 중인지 확인 (수정 시에는 카테고리 코드 검사 제외)
|
||||||
|
const isDuplicateChecking =
|
||||||
|
(!isEditing && categoryCodeCheck.isLoading) || categoryNameCheck.isLoading || categoryNameEngCheck.isLoading;
|
||||||
|
|
||||||
// 폼은 조건부로 직접 사용
|
// 폼은 조건부로 직접 사용
|
||||||
|
|
||||||
// 편집 모드일 때 기존 데이터 로드
|
// 편집 모드일 때 기존 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
// 검증 상태 초기화
|
||||||
|
setValidatedFields(new Set());
|
||||||
|
|
||||||
if (isEditing && editingCategory) {
|
if (isEditing && editingCategory) {
|
||||||
// 수정 모드: 기존 데이터 로드
|
// 수정 모드: 기존 데이터 로드
|
||||||
updateForm.reset({
|
updateForm.reset({
|
||||||
|
|
@ -132,7 +136,7 @@ export function CodeCategoryFormModal({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, isEditing, editingCategory, categories]);
|
}, [isOpen, isEditing, editingCategory, categories, createForm, updateForm]);
|
||||||
|
|
||||||
const handleSubmit = isEditing
|
const handleSubmit = isEditing
|
||||||
? updateForm.handleSubmit(async (data) => {
|
? updateForm.handleSubmit(async (data) => {
|
||||||
|
|
@ -177,7 +181,7 @@ export function CodeCategoryFormModal({
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="카테고리 코드를 입력하세요"
|
placeholder="카테고리 코드를 입력하세요"
|
||||||
className={createForm.formState.errors.categoryCode ? "border-red-500" : ""}
|
className={createForm.formState.errors.categoryCode ? "border-red-500" : ""}
|
||||||
onBlur={formValidation.createBlurHandler("categoryCode")}
|
onBlur={() => handleFieldBlur("categoryCode")}
|
||||||
/>
|
/>
|
||||||
{createForm.formState.errors.categoryCode && (
|
{createForm.formState.errors.categoryCode && (
|
||||||
<p className="text-sm text-red-600">{createForm.formState.errors.categoryCode.message}</p>
|
<p className="text-sm text-red-600">{createForm.formState.errors.categoryCode.message}</p>
|
||||||
|
|
@ -218,7 +222,7 @@ export function CodeCategoryFormModal({
|
||||||
? "border-red-500"
|
? "border-red-500"
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onBlur={formValidation.createBlurHandler("categoryName")}
|
onBlur={() => handleFieldBlur("categoryName")}
|
||||||
/>
|
/>
|
||||||
{isEditing
|
{isEditing
|
||||||
? updateForm.formState.errors.categoryName && (
|
? updateForm.formState.errors.categoryName && (
|
||||||
|
|
@ -253,7 +257,7 @@ export function CodeCategoryFormModal({
|
||||||
? "border-red-500"
|
? "border-red-500"
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onBlur={formValidation.createBlurHandler("categoryNameEng")}
|
onBlur={() => handleFieldBlur("categoryNameEng")}
|
||||||
/>
|
/>
|
||||||
{isEditing
|
{isEditing
|
||||||
? updateForm.formState.errors.categoryNameEng && (
|
? updateForm.formState.errors.categoryNameEng && (
|
||||||
|
|
@ -291,7 +295,7 @@ export function CodeCategoryFormModal({
|
||||||
? "border-red-500"
|
? "border-red-500"
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
onBlur={formValidation.createBlurHandler("description")}
|
onBlur={() => handleFieldBlur("description")}
|
||||||
/>
|
/>
|
||||||
{isEditing
|
{isEditing
|
||||||
? updateForm.formState.errors.description && (
|
? updateForm.formState.errors.description && (
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getItemId: (code: CodeInfo) => code.code_value,
|
getItemId: (code: CodeInfo) => code.codeValue || code.code_value,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 새 코드 생성
|
// 새 코드 생성
|
||||||
|
|
@ -95,7 +95,7 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
try {
|
try {
|
||||||
await deleteCodeMutation.mutateAsync({
|
await deleteCodeMutation.mutateAsync({
|
||||||
categoryCode,
|
categoryCode,
|
||||||
codeValue: deletingCode.code_value,
|
codeValue: deletingCode.codeValue || deletingCode.code_value,
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
|
|
@ -182,13 +182,13 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<DndContext {...dragAndDrop.dndContextProps}>
|
<DndContext {...dragAndDrop.dndContextProps}>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={filteredCodes.map((code) => code.code_value)}
|
items={filteredCodes.map((code) => code.codeValue || code.code_value)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filteredCodes.map((code, index) => (
|
{filteredCodes.map((code, index) => (
|
||||||
<SortableCodeItem
|
<SortableCodeItem
|
||||||
key={`${code.code_value}-${index}`}
|
key={`${code.codeValue || code.code_value}-${index}`}
|
||||||
code={code}
|
code={code}
|
||||||
categoryCode={categoryCode}
|
categoryCode={categoryCode}
|
||||||
onEdit={() => handleEditCode(code)}
|
onEdit={() => handleEditCode(code)}
|
||||||
|
|
@ -208,20 +208,28 @@ export function CodeDetailPanel({ categoryCode }: CodeDetailPanelProps) {
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-medium text-gray-900">{activeCode.code_name}</h3>
|
<h3 className="font-medium text-gray-900">
|
||||||
|
{activeCode.codeName || activeCode.code_name}
|
||||||
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant={activeCode.is_active === "Y" ? "default" : "secondary"}
|
variant={
|
||||||
|
activeCode.isActive === "Y" || activeCode.is_active === "Y"
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
activeCode.is_active === "Y"
|
activeCode.isActive === "Y" || activeCode.is_active === "Y"
|
||||||
? "bg-green-100 text-green-800"
|
? "bg-green-100 text-green-800"
|
||||||
: "bg-gray-100 text-gray-600",
|
: "bg-gray-100 text-gray-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeCode.is_active === "Y" ? "활성" : "비활성"}
|
{activeCode.isActive === "Y" || activeCode.is_active === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-600">{activeCode.code_value}</p>
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
|
{activeCode.codeValue || activeCode.code_value}
|
||||||
|
</p>
|
||||||
{activeCode.description && (
|
{activeCode.description && (
|
||||||
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
|
<p className="mt-1 text-sm text-gray-500">{activeCode.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
||||||
categoryCode,
|
categoryCode,
|
||||||
"codeValue",
|
"codeValue",
|
||||||
validationStates.codeValue.value,
|
validationStates.codeValue.value,
|
||||||
isEditing ? editingCode?.code_value : undefined,
|
isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
|
||||||
validationStates.codeValue.enabled,
|
validationStates.codeValue.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
||||||
categoryCode,
|
categoryCode,
|
||||||
"codeName",
|
"codeName",
|
||||||
validationStates.codeName.value,
|
validationStates.codeName.value,
|
||||||
isEditing ? editingCode?.code_value : undefined,
|
isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
|
||||||
validationStates.codeName.enabled,
|
validationStates.codeName.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
||||||
categoryCode,
|
categoryCode,
|
||||||
"codeNameEng",
|
"codeNameEng",
|
||||||
validationStates.codeNameEng.value,
|
validationStates.codeNameEng.value,
|
||||||
isEditing ? editingCode?.code_value : undefined,
|
isEditing ? editingCode?.codeValue || editingCode?.code_value : undefined,
|
||||||
validationStates.codeNameEng.enabled,
|
validationStates.codeNameEng.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -102,18 +102,18 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
||||||
if (isEditing && editingCode) {
|
if (isEditing && editingCode) {
|
||||||
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
|
// 수정 모드: 기존 데이터 로드 (codeValue는 표시용으로만 설정)
|
||||||
form.reset({
|
form.reset({
|
||||||
codeName: editingCode.code_name,
|
codeName: editingCode.codeName || editingCode.code_name,
|
||||||
codeNameEng: editingCode.code_name_eng || "",
|
codeNameEng: editingCode.codeNameEng || editingCode.code_name_eng || "",
|
||||||
description: editingCode.description || "",
|
description: editingCode.description || "",
|
||||||
sortOrder: editingCode.sort_order,
|
sortOrder: editingCode.sortOrder || editingCode.sort_order,
|
||||||
isActive: editingCode.is_active as "Y" | "N", // 타입 캐스팅
|
isActive: (editingCode.isActive || editingCode.is_active) as "Y" | "N", // 타입 캐스팅
|
||||||
});
|
});
|
||||||
|
|
||||||
// codeValue는 별도로 설정 (표시용)
|
// codeValue는 별도로 설정 (표시용)
|
||||||
form.setValue("codeValue" as any, editingCode.code_value);
|
form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value);
|
||||||
} else {
|
} else {
|
||||||
// 새 코드 모드: 자동 순서 계산
|
// 새 코드 모드: 자동 순서 계산
|
||||||
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sort_order)) : 0;
|
const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order)) : 0;
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
codeValue: "",
|
codeValue: "",
|
||||||
|
|
@ -132,7 +132,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
||||||
// 수정
|
// 수정
|
||||||
await updateCodeMutation.mutateAsync({
|
await updateCodeMutation.mutateAsync({
|
||||||
categoryCode,
|
categoryCode,
|
||||||
codeValue: editingCode.code_value,
|
codeValue: editingCode.codeValue || editingCode.code_value,
|
||||||
data: data as UpdateCodeData,
|
data: data as UpdateCodeData,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export function SortableCodeItem({
|
||||||
isDragOverlay = false,
|
isDragOverlay = false,
|
||||||
}: SortableCodeItemProps) {
|
}: SortableCodeItemProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: code.code_value,
|
id: code.codeValue || code.code_value,
|
||||||
disabled: isDragOverlay,
|
disabled: isDragOverlay,
|
||||||
});
|
});
|
||||||
const updateCodeMutation = useUpdateCode();
|
const updateCodeMutation = useUpdateCode();
|
||||||
|
|
@ -39,14 +39,20 @@ export function SortableCodeItem({
|
||||||
// 활성/비활성 토글 핸들러
|
// 활성/비활성 토글 핸들러
|
||||||
const handleToggleActive = async (checked: boolean) => {
|
const handleToggleActive = async (checked: boolean) => {
|
||||||
try {
|
try {
|
||||||
|
// codeValue 또는 code_value가 없으면 에러 처리
|
||||||
|
const codeValue = code.codeValue || code.code_value;
|
||||||
|
if (!codeValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await updateCodeMutation.mutateAsync({
|
await updateCodeMutation.mutateAsync({
|
||||||
categoryCode,
|
categoryCode,
|
||||||
codeValue: code.code_value,
|
codeValue: codeValue,
|
||||||
data: {
|
data: {
|
||||||
codeName: code.code_name,
|
codeName: code.codeName || code.code_name,
|
||||||
codeNameEng: code.code_name_eng || "",
|
codeNameEng: code.codeNameEng || code.code_name_eng || "",
|
||||||
description: code.description || "",
|
description: code.description || "",
|
||||||
sortOrder: code.sort_order,
|
sortOrder: code.sortOrder || code.sort_order,
|
||||||
isActive: checked ? "Y" : "N",
|
isActive: checked ? "Y" : "N",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -70,12 +76,12 @@ export function SortableCodeItem({
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-medium text-gray-900">{code.code_name}</h3>
|
<h3 className="font-medium text-gray-900">{code.codeName || code.code_name}</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant={code.is_active === "Y" ? "default" : "secondary"}
|
variant={code.isActive === "Y" || code.is_active === "Y" ? "default" : "secondary"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer transition-colors",
|
"cursor-pointer transition-colors",
|
||||||
code.is_active === "Y"
|
code.isActive === "Y" || code.is_active === "Y"
|
||||||
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
|
? "bg-green-100 text-green-800 hover:bg-green-200 hover:text-green-900"
|
||||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-700",
|
||||||
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
|
updateCodeMutation.isPending && "cursor-not-allowed opacity-50",
|
||||||
|
|
@ -84,16 +90,17 @@ export function SortableCodeItem({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!updateCodeMutation.isPending) {
|
if (!updateCodeMutation.isPending) {
|
||||||
handleToggleActive(code.is_active !== "Y");
|
const isActive = code.isActive === "Y" || code.is_active === "Y";
|
||||||
|
handleToggleActive(!isActive);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{code.is_active === "Y" ? "활성" : "비활성"}
|
{code.isActive === "Y" || code.is_active === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-600">{code.code_value}</p>
|
<p className="mt-1 text-sm text-gray-600">{code.codeValue || code.code_value}</p>
|
||||||
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
|
{code.description && <p className="mt-1 text-sm text-gray-500">{code.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,8 +215,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
checkIsMobile();
|
checkIsMobile();
|
||||||
window.addEventListener('resize', checkIsMobile);
|
window.addEventListener("resize", checkIsMobile);
|
||||||
return () => window.removeEventListener('resize', checkIsMobile);
|
return () => window.removeEventListener("resize", checkIsMobile);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 프로필 관련 로직
|
// 프로필 관련 로직
|
||||||
|
|
@ -322,18 +322,20 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div key={menu.id}>
|
<div key={menu.id}>
|
||||||
<div
|
<div
|
||||||
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out h-10 ${
|
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
|
||||||
pathname === menu.url
|
pathname === menu.url
|
||||||
? "bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900 border-l-4 border-blue-500"
|
? "border-l-4 border-blue-500 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||||
: isExpanded
|
: isExpanded
|
||||||
? "bg-slate-100 text-slate-900"
|
? "bg-slate-100 text-slate-900"
|
||||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||||
} ${level > 0 ? "ml-6" : ""}`}
|
} ${level > 0 ? "ml-6" : ""}`}
|
||||||
onClick={() => handleMenuClick(menu)}
|
onClick={() => handleMenuClick(menu)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
{menu.icon}
|
{menu.icon}
|
||||||
<span className="ml-3 truncate" title={menu.name}>{menu.name}</span>
|
<span className="ml-3 truncate" title={menu.name}>
|
||||||
|
{menu.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{menu.hasChildren && (
|
{menu.hasChildren && (
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
|
|
@ -350,14 +352,16 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
key={child.id}
|
key={child.id}
|
||||||
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
|
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
|
||||||
pathname === child.url
|
pathname === child.url
|
||||||
? "bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900 border-l-4 border-blue-500"
|
? "border-l-4 border-blue-500 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleMenuClick(child)}
|
onClick={() => handleMenuClick(child)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
{child.icon}
|
{child.icon}
|
||||||
<span className="ml-3 truncate" title={child.name}>{child.name}</span>
|
<span className="ml-3 truncate" title={child.name}>
|
||||||
|
{child.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -408,8 +412,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
className={`${
|
className={`${
|
||||||
isMobile
|
isMobile
|
||||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
||||||
: "translate-x-0 relative top-0 z-auto"
|
: "relative top-0 z-auto translate-x-0"
|
||||||
} flex h-full w-72 min-w-72 max-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
} flex h-full w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||||
>
|
>
|
||||||
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
{(user as ExtendedUserInfo)?.userType === "admin" && (
|
{(user as ExtendedUserInfo)?.userType === "admin" && (
|
||||||
|
|
@ -453,7 +457,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
|
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
|
||||||
<main className="flex-1 min-w-0 bg-white overflow-auto">{children}</main>
|
<main className="min-w-0 flex-1 overflow-auto bg-white">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 프로필 수정 모달 */}
|
{/* 프로필 수정 모달 */}
|
||||||
|
|
@ -461,7 +465,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
user={user}
|
user={user}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
selectedImage={selectedImage}
|
selectedImage={selectedImage || ""}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
departments={departments}
|
departments={departments}
|
||||||
alertModal={alertModal}
|
alertModal={alertModal}
|
||||||
|
|
|
||||||
|
|
@ -101,17 +101,21 @@ export function ProfileModal({
|
||||||
{/* 프로필 사진 섹션 */}
|
{/* 프로필 사진 섹션 */}
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-24 w-24">
|
<div className="relative flex h-24 w-24 shrink-0 overflow-hidden rounded-full">
|
||||||
{selectedImage ? (
|
{selectedImage && selectedImage.trim() !== "" ? (
|
||||||
<AvatarImage src={selectedImage} alt="프로필 사진 미리보기" />
|
<img
|
||||||
) : user?.photo ? (
|
src={selectedImage}
|
||||||
<AvatarImage src={user.photo} alt="기존 프로필 사진" />
|
alt="프로필 사진 미리보기"
|
||||||
|
className="aspect-square h-full w-full object-cover"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarFallback className="text-lg">{formData.userName?.substring(0, 1) || "U"}</AvatarFallback>
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-2xl font-semibold text-slate-700">
|
||||||
|
{formData.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</div>
|
||||||
|
|
||||||
{(selectedImage || user?.photo) && (
|
{selectedImage && selectedImage.trim() !== "" ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|
@ -121,7 +125,7 @@ export function ProfileModal({
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -26,20 +26,38 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||||
<Avatar className="h-8 w-8">
|
<div className="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full">
|
||||||
{user.photo ? <AvatarImage src={user.photo} alt={user.userName || "User"} /> : null}
|
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||||
<AvatarFallback>{user.userName?.substring(0, 1) || "U"}</AvatarFallback>
|
<img
|
||||||
</Avatar>
|
src={user.photo}
|
||||||
|
alt={user.userName || "User"}
|
||||||
|
className="aspect-square h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 font-semibold text-slate-700">
|
||||||
|
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="end">
|
<DropdownMenuContent className="w-56" align="end">
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{/* 프로필 사진 표시 */}
|
{/* 프로필 사진 표시 */}
|
||||||
<Avatar className="h-12 w-12">
|
<div className="relative flex h-12 w-12 shrink-0 overflow-hidden rounded-full">
|
||||||
{user.photo ? <AvatarImage src={user.photo} alt={user.userName || "User"} /> : null}
|
{user.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||||
<AvatarFallback>{user.userName?.substring(0, 1) || "U"}</AvatarFallback>
|
<img
|
||||||
</Avatar>
|
src={user.photo}
|
||||||
|
alt={user.userName || "User"}
|
||||||
|
className="aspect-square h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center rounded-full bg-slate-200 text-base font-semibold text-slate-700">
|
||||||
|
{user.userName?.substring(0, 1)?.toUpperCase() || "U"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 사용자 정보 */}
|
{/* 사용자 정보 */}
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
maxHeight: "95vh",
|
maxHeight: "95vh",
|
||||||
zIndex: 9999, // 모든 컴포넌트보다 위에 표시
|
zIndex: 9999, // 모든 컴포넌트보다 위에 표시
|
||||||
}}
|
}}
|
||||||
|
data-radix-portal="true"
|
||||||
>
|
>
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
<DialogTitle>수정</DialogTitle>
|
<DialogTitle>수정</DialogTitle>
|
||||||
|
|
@ -271,16 +272,17 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
>
|
>
|
||||||
{/* 화면 컴포넌트들 원본 레이아웃 유지하여 렌더링 */}
|
{/* 화면 컴포넌트들 원본 레이아웃 유지하여 렌더링 */}
|
||||||
<div className="relative" style={{ minHeight: "300px" }}>
|
<div className="relative" style={{ minHeight: "300px" }}>
|
||||||
{components.map((component) => (
|
{components.map((component, index) => (
|
||||||
<div
|
<div
|
||||||
key={component.id}
|
key={component.id}
|
||||||
|
className="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"
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: component.position?.y || 0,
|
top: component.position?.y || 0,
|
||||||
left: component.position?.x || 0,
|
left: component.position?.x || 0,
|
||||||
width: component.size?.width || 200,
|
width: component.size?.width || 200,
|
||||||
height: component.size?.height || 40,
|
height: component.size?.height || 40,
|
||||||
zIndex: 1,
|
zIndex: component.position?.z || (1000 + index), // 모달 내부에서 충분히 높은 z-index
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */}
|
{/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */}
|
||||||
|
|
@ -288,7 +290,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={component}
|
component={component}
|
||||||
allComponents={components}
|
allComponents={components}
|
||||||
hideLabel={false} // 라벨 표시 활성화
|
hideLabel={true} // 라벨 숨김 (원래 화면과 동일하게)
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
console.log("📝 폼 데이터 변경:", fieldName, value);
|
console.log("📝 폼 데이터 변경:", fieldName, value);
|
||||||
|
|
@ -312,7 +314,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
...component,
|
...component,
|
||||||
style: {
|
style: {
|
||||||
...component.style,
|
...component.style,
|
||||||
labelDisplay: true, // 수정 모달에서는 라벨 강제 표시
|
labelDisplay: false, // 라벨 숨김 (원래 화면과 동일하게)
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
screenId={screenId}
|
screenId={screenId}
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={cn(
|
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",
|
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||||
isResizing && "cursor-se-resize",
|
isResizing && "cursor-se-resize",
|
||||||
className,
|
className,
|
||||||
|
|
@ -246,7 +246,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
<div
|
<div
|
||||||
ref={dragHandleRef}
|
ref={dragHandleRef}
|
||||||
data-header="true"
|
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}
|
onMouseDown={handleDragStart}
|
||||||
style={{
|
style={{
|
||||||
userSelect: "none", // 텍스트 선택 방지
|
userSelect: "none", // 텍스트 선택 방지
|
||||||
|
|
@ -259,8 +259,8 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="rounded p-1 transition-colors hover:bg-gray-200">
|
<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" />
|
<X className="h-4 w-4 text-gray-500 hover:text-gray-700" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -282,7 +282,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
{/* 리사이즈 핸들 */}
|
{/* 리사이즈 핸들 */}
|
||||||
{resizable && !autoHeight && (
|
{resizable && !autoHeight && (
|
||||||
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Grid,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
|
|
@ -1721,7 +1722,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="p-6 pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -1811,7 +1812,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{visibleColumns.length > 0 ? (
|
{visibleColumns.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Table>
|
<div className="rounded-lg border border-gray-200/60 bg-white shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||||
|
|
@ -1826,7 +1828,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
{visibleColumns.map((column: DataTableColumn) => (
|
{visibleColumns.map((column: DataTableColumn) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={column.id}
|
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}%` }}
|
style={{ width: `${((column.gridColumns || 2) / totalGridColumns) * 100}%` }}
|
||||||
>
|
>
|
||||||
{column.label}
|
{column.label}
|
||||||
|
|
@ -1850,7 +1852,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : data.length > 0 ? (
|
) : data.length > 0 ? (
|
||||||
data.map((row, rowIndex) => (
|
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 && (
|
{component.enableDelete && (
|
||||||
<TableCell className="w-12 px-4">
|
<TableCell className="w-12 px-4">
|
||||||
|
|
@ -1861,7 +1863,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{visibleColumns.map((column: DataTableColumn) => (
|
{visibleColumns.map((column: DataTableColumn) => (
|
||||||
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
<TableCell key={column.id} className="px-4 text-sm font-medium text-gray-900">
|
||||||
{formatCellValue(row[column.columnName], column, row)}
|
{formatCellValue(row[column.columnName], column, row)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1884,10 +1886,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{component.pagination?.enabled && totalPages > 1 && (
|
{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">
|
<div className="flex items-center justify-between px-6 py-3">
|
||||||
{component.pagination.showPageInfo && (
|
{component.pagination.showPageInfo && (
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
||||||
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
import { FormValidationIndicator } from "@/components/common/FormValidationIndicator";
|
||||||
import { useFormValidation } from "@/hooks/useFormValidation";
|
import { useFormValidation } from "@/hooks/useFormValidation";
|
||||||
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
|
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
|
||||||
|
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
|
|
||||||
interface InteractiveScreenViewerProps {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -771,11 +772,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
const currentValue = getCurrentValue();
|
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 위젯:", {
|
console.log("📁 InteractiveScreenViewer - File 위젯:", {
|
||||||
componentId: widget.id,
|
componentId: widget.id,
|
||||||
widgetType: widget.widgetType,
|
widgetType: widget.widgetType,
|
||||||
config,
|
config,
|
||||||
currentValue,
|
currentValue,
|
||||||
|
screenId,
|
||||||
appliedSettings: {
|
appliedSettings: {
|
||||||
accept: config?.accept,
|
accept: config?.accept,
|
||||||
multiple: config?.multiple,
|
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;
|
const fileComponent = component as FileComponent;
|
||||||
|
|
||||||
console.log("🎯 File 컴포넌트 렌더링:", {
|
console.log("🎯 File 컴포넌트 렌더링:", {
|
||||||
|
|
@ -1720,13 +1727,15 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
{shouldShowLabel && (
|
{shouldShowLabel && (
|
||||||
<div className="block" style={labelStyle}>
|
<div className="block mb-3" style={labelStyle}>
|
||||||
{labelText}
|
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold">
|
||||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
{labelText}
|
||||||
</div>
|
{component.required && <span style={{ color: "#f97316", marginLeft: "4px" }}>*</span>}
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 실제 위젯 */}
|
{/* 실제 위젯 */}
|
||||||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
|
|
||||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||||
import "@/lib/registry/components/ButtonRenderer";
|
import "@/lib/registry/components/ButtonRenderer";
|
||||||
|
|
@ -143,7 +144,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// 동적 대화형 위젯 렌더링
|
// 동적 대화형 위젯 렌더링
|
||||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||||
// 데이터 테이블 컴포넌트 처리
|
// 데이터 테이블 컴포넌트 처리
|
||||||
if (comp.type === "datatable") {
|
if (isDataTableComponent(comp)) {
|
||||||
return (
|
return (
|
||||||
<InteractiveDataTable
|
<InteractiveDataTable
|
||||||
component={comp as DataTableComponent}
|
component={comp as DataTableComponent}
|
||||||
|
|
@ -157,12 +158,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버튼 컴포넌트 처리
|
// 버튼 컴포넌트 처리
|
||||||
if (comp.type === "button") {
|
if (isButtonComponent(comp)) {
|
||||||
return renderButton(comp);
|
return renderButton(comp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 컴포넌트 처리
|
// 파일 컴포넌트 처리
|
||||||
if (comp.type === "file") {
|
if (isFileComponent(comp)) {
|
||||||
return renderFileComponent(comp as FileComponent);
|
return renderFileComponent(comp as FileComponent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,6 +414,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const { label, readonly } = comp;
|
const { label, readonly } = comp;
|
||||||
const fieldName = comp.columnName || comp.id;
|
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 (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
{/* 실제 FileUploadComponent 사용 */}
|
{/* 실제 FileUploadComponent 사용 */}
|
||||||
|
|
@ -433,12 +440,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
formData={{
|
formData={{
|
||||||
tableName: screenInfo?.tableName,
|
screenId, // 🎯 화면 ID 전달
|
||||||
|
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
||||||
|
autoLink: true, // 자동 연결 활성화
|
||||||
|
linkedTable: 'screen_files', // 연결 테이블
|
||||||
|
recordId: screenId, // 레코드 ID
|
||||||
|
columnName: fieldName, // 컬럼명 (중요!)
|
||||||
|
isVirtualFileColumn: true, // 가상 파일 컬럼
|
||||||
id: formData.id,
|
id: formData.id,
|
||||||
...formData
|
...formData
|
||||||
}}
|
}}
|
||||||
onFormDataChange={(data) => {
|
onFormDataChange={(data) => {
|
||||||
console.log("📝 파일 업로드 완료:", data);
|
console.log("📝 실제 화면 파일 업로드 완료:", data);
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
onFormDataChange(key, value);
|
onFormDataChange(key, value);
|
||||||
|
|
@ -446,11 +459,57 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onUpdate={(updates) => {
|
onUpdate={(updates) => {
|
||||||
console.log("🔄 파일 컴포넌트 업데이트:", updates);
|
console.log("🔄🔄🔄 실제 화면 파일 컴포넌트 업데이트:", {
|
||||||
// 파일 업로드 완료 시 formData 업데이트
|
componentId: comp.id,
|
||||||
|
hasUploadedFiles: !!updates.uploadedFiles,
|
||||||
|
filesCount: updates.uploadedFiles?.length || 0,
|
||||||
|
hasLastFileUpdate: !!updates.lastFileUpdate,
|
||||||
|
updates
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파일 업로드/삭제 완료 시 formData 업데이터
|
||||||
if (updates.uploadedFiles && onFormDataChange) {
|
if (updates.uploadedFiles && onFormDataChange) {
|
||||||
onFormDataChange(fieldName, updates.uploadedFiles);
|
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>
|
</div>
|
||||||
|
|
@ -474,18 +533,10 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
<>
|
<>
|
||||||
<div className="absolute" style={componentStyle}>
|
<div className="absolute" style={componentStyle}>
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
{/* 라벨 표시 - 컴포넌트 내부에서 라벨을 처리하므로 외부에서는 표시하지 않음 */}
|
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
||||||
{!hideLabel && component.label && component.style?.labelDisplay === false && (
|
|
||||||
<div className="mb-1">
|
|
||||||
<label className="text-sm font-medium text-gray-700">
|
|
||||||
{component.label}
|
|
||||||
{(component as WidgetComponent).required && <span className="ml-1 text-red-500">*</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 위젯 렌더링 */}
|
{/* 위젯 렌더링 */}
|
||||||
<div className="flex-1">{renderInteractiveWidget(component)}</div>
|
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
|
||||||
|
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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}`,
|
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) {
|
if (widgetType) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -209,6 +221,97 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { type, id, position, size, style = {} } = component;
|
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') {
|
||||||
|
try {
|
||||||
|
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
|
||||||
|
// 전역 강제 업데이트 함수 등록
|
||||||
|
if (!(window as any).forceRealtimePreviewUpdate) {
|
||||||
|
(window as any).forceRealtimePreviewUpdate = forceUpdate;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("RealtimePreview 이벤트 리스너 등록 실패:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [component.id, fileUpdateTrigger]);
|
||||||
|
|
||||||
// 컴포넌트 스타일 계산
|
// 컴포넌트 스타일 계산
|
||||||
const componentStyle = {
|
const componentStyle = {
|
||||||
|
|
@ -299,8 +402,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 위젯 타입 - 동적 렌더링 */}
|
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||||
{type === "widget" && (
|
{type === "widget" && !isFileComponent(component) && (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="pointer-events-none flex-1">
|
<div className="pointer-events-none flex-1">
|
||||||
<WidgetRenderer component={component} />
|
<WidgetRenderer component={component} />
|
||||||
|
|
@ -308,8 +411,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 파일 타입 */}
|
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
||||||
{type === "file" && (() => {
|
{isFileComponent(component) && (() => {
|
||||||
const fileComponent = component as any;
|
const fileComponent = component as any;
|
||||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||||
|
|
||||||
|
|
@ -327,11 +430,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
currentFilesCount: currentFiles.length,
|
currentFilesCount: currentFiles.length,
|
||||||
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
||||||
componentType: component.type,
|
componentType: component.type,
|
||||||
|
fileUpdateTrigger: fileUpdateTrigger,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||||
{currentFiles.length > 0 ? (
|
{currentFiles.length > 0 ? (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
|
|
|
||||||
|
|
@ -734,6 +734,100 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
initComponents();
|
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(() => {
|
useEffect(() => {
|
||||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
|
|
@ -3302,7 +3396,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<DesignerToolbar
|
||||||
screenName={selectedScreen?.screenName}
|
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="mb-4 flex items-center justify-center">
|
||||||
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
|
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default function ColumnComponent({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded border border-gray-200 p-2",
|
"flex-1 rounded border border-gray-200 p-2",
|
||||||
isSelected && "border-blue-500 bg-blue-50",
|
isSelected && "border-blue-500 bg-blue-50",
|
||||||
isMoving && "cursor-move shadow-lg",
|
isMoving && "cursor-move",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default function ContainerComponent({
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
|
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
|
||||||
isSelected && "border-blue-500 bg-blue-50",
|
isSelected && "border-blue-500 bg-blue-50",
|
||||||
isMoving && "cursor-move shadow-lg",
|
isMoving && "cursor-move",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default function RowComponent({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-4 rounded border border-gray-200 p-2",
|
"flex gap-4 rounded border border-gray-200 p-2",
|
||||||
isSelected && "border-blue-500 bg-blue-50",
|
isSelected && "border-blue-500 bg-blue-50",
|
||||||
isMoving && "cursor-move shadow-lg",
|
isMoving && "cursor-move",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
|
|
|
||||||
|
|
@ -125,126 +125,145 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<div className={`h-full bg-gradient-to-br from-slate-50 to-purple-50/30 border-r border-gray-200/60 shadow-sm ${className}`}>
|
||||||
<CardHeader className="pb-3">
|
<div className="p-6">
|
||||||
<CardTitle className="flex items-center justify-between">
|
{/* 헤더 */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Package className="mr-2 h-5 w-5" />
|
<div>
|
||||||
컴포넌트 ({componentsByCategory.all.length})
|
<h2 className="text-lg font-semibold text-gray-900 mb-1">컴포넌트</h2>
|
||||||
|
<p className="text-sm text-gray-500">{componentsByCategory.all.length}개의 사용 가능한 컴포넌트</p>
|
||||||
</div>
|
</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" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardTitle>
|
</div>
|
||||||
|
|
||||||
{/* 검색창 */}
|
{/* 검색창 */}
|
||||||
<div className="relative">
|
<div className="relative mb-6">
|
||||||
<Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="컴포넌트 검색..."
|
placeholder="컴포넌트 검색..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
|
||||||
<CardContent>
|
<div className="px-6">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
||||||
>
|
>
|
||||||
{/* 카테고리 탭 (input 카테고리 제외) */}
|
{/* 카테고리 탭 (input 카테고리 제외) */}
|
||||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
|
<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">
|
<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" />
|
<Package className="mr-1 h-3 w-3" />
|
||||||
전체
|
전체
|
||||||
</TabsTrigger>
|
</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" />
|
<Palette className="mr-1 h-3 w-3" />
|
||||||
표시
|
표시
|
||||||
</TabsTrigger>
|
</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" />
|
<Zap className="mr-1 h-3 w-3" />
|
||||||
액션
|
액션
|
||||||
</TabsTrigger>
|
</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" />
|
<Layers className="mr-1 h-3 w-3" />
|
||||||
레이아웃
|
레이아웃
|
||||||
</TabsTrigger>
|
</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" />
|
<Package className="mr-1 h-3 w-3" />
|
||||||
유틸
|
유틸리티
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 컴포넌트 목록 */}
|
{/* 컴포넌트 목록 */}
|
||||||
<div className="mt-4">
|
<div className="mt-6">
|
||||||
<TabsContent value={selectedCategory} className="space-y-2">
|
<TabsContent value={selectedCategory} className="space-y-3">
|
||||||
{filteredComponents.length > 0 ? (
|
{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) => (
|
{filteredComponents.map((component) => (
|
||||||
<div
|
<div
|
||||||
key={component.id}
|
key={component.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, component)}
|
onDragStart={(e) => {
|
||||||
className="hover:bg-accent flex cursor-grab items-center rounded-lg border p-3 transition-colors active:cursor-grabbing"
|
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}
|
title={component.description}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-start space-x-4">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<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">
|
||||||
<h4 className="truncate text-sm font-medium">{component.name}</h4>
|
{getCategoryIcon(component.category)}
|
||||||
<div className="flex items-center space-x-1">
|
</div>
|
||||||
{/* 카테고리 뱃지 */}
|
<div className="min-w-0 flex-1">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<div className="flex items-start justify-between mb-2">
|
||||||
{getCategoryIcon(component.category)}
|
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{component.name}</h4>
|
||||||
<span className="ml-1">{component.category}</span>
|
<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>
|
|
||||||
|
|
||||||
{/* 새 컴포넌트 뱃지 */}
|
|
||||||
<Badge variant="default" className="bg-green-500 text-xs">
|
|
||||||
신규
|
신규
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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="flex items-center justify-between mb-2">
|
||||||
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
|
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||||
<span>웹타입: {component.webType}</span>
|
<span className="bg-gradient-to-r from-gray-100 to-gray-200 px-3 py-1 rounded-full font-medium text-gray-600">
|
||||||
<span>
|
{component.defaultSize.width}×{component.defaultSize.height}
|
||||||
{component.defaultSize.width}×{component.defaultSize.height}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</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>
|
||||||
{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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground py-8 text-center">
|
<div className="py-12 text-center text-gray-500">
|
||||||
<Package className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
<div className="p-8">
|
||||||
<p className="text-sm">
|
<Package className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||||
{searchQuery
|
<p className="text-sm font-medium text-gray-600">
|
||||||
? `"${searchQuery}"에 대한 검색 결과가 없습니다.`
|
{searchQuery
|
||||||
: "이 카테고리에 컴포넌트가 없습니다."}
|
? `"${searchQuery}"에 대한 컴포넌트를 찾을 수 없습니다`
|
||||||
</p>
|
: "이 카테고리에 컴포넌트가 없습니다"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -252,31 +271,40 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
</Tabs>
|
</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 className="grid grid-cols-2 gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-bold text-green-600">{filteredComponents.length}</div>
|
<div className="text-lg font-bold text-emerald-600">{filteredComponents.length}</div>
|
||||||
<div className="text-muted-foreground text-xs">표시된 컴포넌트</div>
|
<div className="text-xs text-gray-500">필터됨</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-bold text-blue-600">{allComponents.length}</div>
|
<div className="text-lg font-bold text-purple-600">{allComponents.length}</div>
|
||||||
<div className="text-muted-foreground text-xs">전체 컴포넌트</div>
|
<div className="text-xs text-gray-500">전체</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 개발 정보 (개발 모드에서만) */}
|
{/* 개발 정보 (개발 모드에서만) */}
|
||||||
{process.env.NODE_ENV === "development" && (
|
{process.env.NODE_ENV === "development" && (
|
||||||
<div className="mt-4 border-t pt-3">
|
<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="text-muted-foreground space-y-1 text-xs">
|
<div className="space-y-1 text-xs text-gray-600">
|
||||||
<div>🔧 레지스트리 기반 시스템</div>
|
<div className="flex items-center space-x-2">
|
||||||
<div>⚡ Hot Reload 지원</div>
|
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||||
<div>🛡️ 완전한 타입 안전성</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "@/types/screen";
|
} from "@/types/screen";
|
||||||
// 레거시 ButtonConfigPanel 제거됨
|
// 레거시 ButtonConfigPanel 제거됨
|
||||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||||
|
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
|
|
||||||
// 새로운 컴포넌트 설정 패널들 import
|
// 새로운 컴포넌트 설정 패널들 import
|
||||||
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||||
|
|
@ -908,7 +909,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
|
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
|
||||||
if (selectedComponent.type === "file" || (selectedComponent.type === "widget" && selectedComponent.widgetType === "file")) {
|
if (isFileComponent(selectedComponent)) {
|
||||||
const fileComponent = selectedComponent as FileComponent;
|
const fileComponent = selectedComponent as FileComponent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { FileComponent, TableInfo } from "@/types/screen";
|
import { FileComponent, TableInfo } from "@/types/screen";
|
||||||
import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide-react";
|
import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
|
||||||
import { formatFileSize } from "@/lib/utils";
|
import { formatFileSize } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -28,6 +28,13 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
currentTable,
|
currentTable,
|
||||||
currentTableName,
|
currentTableName,
|
||||||
}) => {
|
}) => {
|
||||||
|
console.log("🎨🎨🎨 FileComponentConfigPanel 렌더링:", {
|
||||||
|
componentId: component?.id,
|
||||||
|
componentType: component?.type,
|
||||||
|
hasOnUpdateProperty: !!onUpdateProperty,
|
||||||
|
currentTable,
|
||||||
|
currentTableName
|
||||||
|
});
|
||||||
// fileConfig가 없는 경우 초기화
|
// fileConfig가 없는 경우 초기화
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!component.fileConfig) {
|
if (!component.fileConfig) {
|
||||||
|
|
@ -112,13 +119,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const componentFiles = component.uploadedFiles || [];
|
const componentFiles = component.uploadedFiles || [];
|
||||||
const globalFiles = getGlobalFileState()[component.id] || [];
|
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||||
|
|
||||||
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일)
|
// localStorage 백업에서 복원 (영구 저장된 파일 + 임시 파일 + FileUploadComponent 백업)
|
||||||
const backupKey = `fileComponent_${component.id}_files`;
|
const backupKey = `fileComponent_${component.id}_files`;
|
||||||
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
|
const tempBackupKey = `fileComponent_${component.id}_files_temp`;
|
||||||
|
const fileUploadBackupKey = `fileUpload_${component.id}`; // 🎯 실제 화면과 동기화
|
||||||
|
|
||||||
const backupFiles = localStorage.getItem(backupKey);
|
const backupFiles = localStorage.getItem(backupKey);
|
||||||
const tempBackupFiles = localStorage.getItem(tempBackupKey);
|
const tempBackupFiles = localStorage.getItem(tempBackupKey);
|
||||||
|
const fileUploadBackupFiles = localStorage.getItem(fileUploadBackupKey); // 🎯 실제 화면 백업
|
||||||
|
|
||||||
let parsedBackupFiles: FileInfo[] = [];
|
let parsedBackupFiles: FileInfo[] = [];
|
||||||
let parsedTempFiles: FileInfo[] = [];
|
let parsedTempFiles: FileInfo[] = [];
|
||||||
|
let parsedFileUploadFiles: FileInfo[] = []; // 🎯 실제 화면 파일
|
||||||
|
|
||||||
if (backupFiles) {
|
if (backupFiles) {
|
||||||
try {
|
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 :
|
const finalFiles = globalFiles.length > 0 ? globalFiles :
|
||||||
|
parsedFileUploadFiles.length > 0 ? parsedFileUploadFiles : // 🎯 실제 화면 우선
|
||||||
parsedBackupFiles.length > 0 ? parsedBackupFiles :
|
parsedBackupFiles.length > 0 ? parsedBackupFiles :
|
||||||
parsedTempFiles.length > 0 ? parsedTempFiles :
|
parsedTempFiles.length > 0 ? parsedTempFiles :
|
||||||
componentFiles;
|
componentFiles;
|
||||||
|
|
@ -148,8 +170,12 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
globalFiles: globalFiles.length,
|
globalFiles: globalFiles.length,
|
||||||
backupFiles: parsedBackupFiles.length,
|
backupFiles: parsedBackupFiles.length,
|
||||||
tempFiles: parsedTempFiles.length,
|
tempFiles: parsedTempFiles.length,
|
||||||
|
fileUploadFiles: parsedFileUploadFiles.length, // 🎯 실제 화면 파일 수
|
||||||
finalFiles: finalFiles.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;
|
return finalFiles;
|
||||||
|
|
@ -190,7 +216,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
|
|
||||||
// 파일 업로드 처리
|
// 파일 업로드 처리
|
||||||
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
|
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 fileArray = Array.from(files);
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
|
|
@ -291,23 +327,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
|
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
|
||||||
|
|
||||||
// 그리드와 연동되는 targetObjid 생성 (화면 복원 시스템과 통일)
|
// 🎯 여러 방법으로 screenId 확인
|
||||||
const tableName = 'screen_files';
|
let screenId = (window as any).__CURRENT_SCREEN_ID__;
|
||||||
const screenId = (window as any).__CURRENT_SCREEN_ID__ || 'unknown'; // 현재 화면 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 componentId = component.id;
|
||||||
const fieldName = component.columnName || component.id || 'file_attachment';
|
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({
|
const response = await uploadFiles({
|
||||||
files: filesToUpload,
|
files: filesToUpload,
|
||||||
tableName: tableName,
|
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
||||||
fieldName: fieldName,
|
autoLink: true, // 자동 연결 활성화
|
||||||
recordId: `${screenId}:${componentId}`, // 화면ID:컴포넌트ID 형태
|
linkedTable: 'screen_files', // 연결 테이블
|
||||||
|
recordId: screenId, // 레코드 ID
|
||||||
|
columnName: fieldName, // 컬럼명
|
||||||
|
isVirtualFileColumn: true, // 가상 파일 컬럼
|
||||||
docType: localInputs.docType,
|
docType: localInputs.docType,
|
||||||
docTypeName: localInputs.docTypeName,
|
docTypeName: localInputs.docTypeName,
|
||||||
targetObjid: targetObjid, // 그리드 연동을 위한 targetObjid
|
|
||||||
columnName: fieldName,
|
|
||||||
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📤 파일 업로드 응답:", response);
|
console.log("📤 파일 업로드 응답:", response);
|
||||||
|
|
@ -358,6 +420,65 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const backupKey = `fileComponent_${component.id}_files`;
|
const backupKey = `fileComponent_${component.id}_files`;
|
||||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
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 자동 저장:", {
|
console.log("🔄 FileComponentConfigPanel 자동 저장:", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
uploadedFiles: updatedFiles.length,
|
uploadedFiles: updatedFiles.length,
|
||||||
|
|
@ -369,6 +490,11 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
|
|
||||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||||
if (typeof window !== 'undefined') {
|
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', {
|
const refreshEvent = new CustomEvent('refreshFileStatus', {
|
||||||
detail: {
|
detail: {
|
||||||
tableName: tableName,
|
tableName: tableName,
|
||||||
|
|
@ -399,10 +525,18 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('❌ 파일 업로드 오류:', error);
|
console.error('❌ 파일 업로드 오류:', {
|
||||||
|
error,
|
||||||
|
errorMessage: error?.message,
|
||||||
|
errorResponse: error?.response?.data,
|
||||||
|
errorStatus: error?.response?.status,
|
||||||
|
componentId: component?.id,
|
||||||
|
screenId,
|
||||||
|
fieldName
|
||||||
|
});
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
toast.error('파일 업로드에 실패했습니다.');
|
toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`);
|
||||||
} finally {
|
} finally {
|
||||||
console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
|
console.log("🏁 파일 업로드 완료, 로딩 상태 해제");
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
|
@ -413,7 +547,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
||||||
try {
|
try {
|
||||||
await downloadFile({
|
await downloadFile({
|
||||||
fileId: file.objid || file.id,
|
fileId: file.objid || file.id || '',
|
||||||
serverFilename: file.savedFileName,
|
serverFilename: file.savedFileName,
|
||||||
originalName: file.realFileName || file.name || 'download',
|
originalName: file.realFileName || file.name || 'download',
|
||||||
});
|
});
|
||||||
|
|
@ -426,8 +560,17 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
|
|
||||||
// 파일 삭제 처리
|
// 파일 삭제 처리
|
||||||
const handleFileDelete = useCallback(async (fileId: string) => {
|
const handleFileDelete = useCallback(async (fileId: string) => {
|
||||||
|
console.log("🗑️🗑️🗑️ FileComponentConfigPanel 파일 삭제 시작:", {
|
||||||
|
fileId,
|
||||||
|
componentId: component?.id,
|
||||||
|
currentFilesCount: uploadedFiles.length,
|
||||||
|
hasOnUpdateProperty: !!onUpdateProperty
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
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);
|
const updatedFiles = uploadedFiles.filter(file => file.objid !== fileId && file.id !== fileId);
|
||||||
setUploadedFiles(updatedFiles);
|
setUploadedFiles(updatedFiles);
|
||||||
|
|
||||||
|
|
@ -455,23 +598,73 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
timestamp: timestamp
|
timestamp: timestamp
|
||||||
});
|
});
|
||||||
|
|
||||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
// 🎯 RealtimePreview 동기화를 위한 전역 이벤트 발생
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const tableName = currentTableName || 'screen_files';
|
try {
|
||||||
const recordId = component.id;
|
const eventDetail = {
|
||||||
const columnName = component.columnName || component.id || 'file_attachment';
|
componentId: component.id,
|
||||||
const targetObjid = `${tableName}:${recordId}:${columnName}`;
|
files: updatedFiles,
|
||||||
|
fileCount: updatedFiles.length,
|
||||||
|
action: 'delete',
|
||||||
|
timestamp: timestamp,
|
||||||
|
source: 'designMode' // 🎯 화면설계 모드에서 온 이벤트임을 표시
|
||||||
|
};
|
||||||
|
|
||||||
const refreshEvent = new CustomEvent('refreshFileStatus', {
|
console.log("🚀🚀🚀 FileComponentConfigPanel 삭제 이벤트 발생:", eventDetail);
|
||||||
detail: {
|
|
||||||
tableName: tableName,
|
const event = new CustomEvent('globalFileStateChanged', {
|
||||||
recordId: recordId,
|
detail: eventDetail
|
||||||
columnName: columnName,
|
});
|
||||||
targetObjid: targetObjid,
|
window.dispatchEvent(event);
|
||||||
fileCount: updatedFiles.length
|
|
||||||
|
console.log("✅✅✅ globalFileStateChanged 삭제 이벤트 발생 완료");
|
||||||
|
|
||||||
|
// 추가 지연 이벤트들
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
console.log("🔄 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||||
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: { ...eventDetail, delayed: true }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FileComponentConfigPanel 지연 이벤트 발생 실패:", error);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
console.log("🔄 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||||
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FileComponentConfigPanel 지연 이벤트 발생 실패:", error);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FileComponentConfigPanel 이벤트 발생 실패:", error);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
window.dispatchEvent(refreshEvent);
|
// 그리드 파일 상태 새로고침 이벤트도 유지
|
||||||
|
try {
|
||||||
|
const tableName = currentTableName || 'screen_files';
|
||||||
|
const recordId = component.id;
|
||||||
|
const columnName = component.columnName || component.id || 'file_attachment';
|
||||||
|
const targetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||||
|
|
||||||
|
const refreshEvent = new CustomEvent('refreshFileStatus', {
|
||||||
|
detail: {
|
||||||
|
tableName: tableName,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
targetObjid: targetObjid,
|
||||||
|
fileCount: updatedFiles.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(refreshEvent);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FileComponentConfigPanel refreshFileStatus 이벤트 발생 실패:", error);
|
||||||
|
}
|
||||||
console.log("🔄 FileComponentConfigPanel 파일 삭제 후 그리드 새로고침:", {
|
console.log("🔄 FileComponentConfigPanel 파일 삭제 후 그리드 새로고침:", {
|
||||||
tableName,
|
tableName,
|
||||||
recordId,
|
recordId,
|
||||||
|
|
@ -539,12 +732,22 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const files = e.dataTransfer.files;
|
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) {
|
if (files.length > 0) {
|
||||||
handleFileUpload(files);
|
handleFileUpload(files);
|
||||||
}
|
}
|
||||||
}, [handleFileUpload]);
|
}, [handleFileUpload, component?.id]);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
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;
|
const files = e.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
handleFileUpload(files);
|
handleFileUpload(files);
|
||||||
|
|
@ -649,20 +852,49 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
// 전역 파일 상태 변경 감지 (화면 복원 포함)
|
// 전역 파일 상태 변경 감지 (화면 복원 포함)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
const { componentId, files, fileCount, isRestore } = event.detail;
|
const { componentId, files, fileCount, isRestore, source } = event.detail;
|
||||||
|
|
||||||
if (componentId === component.id) {
|
if (componentId === component.id) {
|
||||||
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
|
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
|
||||||
componentId,
|
componentId,
|
||||||
fileCount,
|
fileCount,
|
||||||
isRestore: !!isRestore,
|
isRestore: !!isRestore,
|
||||||
|
source: source || 'unknown',
|
||||||
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||||
});
|
});
|
||||||
|
|
||||||
if (files && Array.isArray(files)) {
|
if (files && Array.isArray(files)) {
|
||||||
setUploadedFiles(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("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
|
console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
|
||||||
componentId,
|
componentId,
|
||||||
restoredFileCount: files.length
|
restoredFileCount: files.length
|
||||||
|
|
@ -679,7 +911,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [component.id]);
|
}, [component.id, onUpdateProperty]);
|
||||||
|
|
||||||
// 미리 정의된 문서 타입들
|
// 미리 정의된 문서 타입들
|
||||||
const docTypeOptions = [
|
const docTypeOptions = [
|
||||||
|
|
@ -875,18 +1107,33 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
{/* 파일 업로드 영역 */}
|
{/* 파일 업로드 영역 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-medium text-gray-900">파일 업로드</h4>
|
<h4 className="text-sm font-medium text-gray-900">파일 업로드</h4>
|
||||||
<Card>
|
<Card className="border-gray-200/60 shadow-sm">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-6">
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-300
|
||||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-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-gray-400'}
|
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm'}
|
||||||
`}
|
`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
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
|
<input
|
||||||
id="file-input-config"
|
id="file-input-config"
|
||||||
|
|
@ -959,7 +1206,7 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export default function LayoutsPanel({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="flex h-full flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
|
|
|
||||||
|
|
@ -487,16 +487,22 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<div className="relative">
|
||||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="템플릿 검색..."
|
placeholder="템플릿 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -508,7 +514,13 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
variant={selectedCategory === category.id ? "default" : "outline"}
|
variant={selectedCategory === category.id ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSelectedCategory(category.id)}
|
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}
|
{category.icon}
|
||||||
<span>{category.name}</span>
|
<span>{category.name}</span>
|
||||||
|
|
@ -517,23 +529,21 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* 새로고침 버튼 */}
|
{/* 새로고침 버튼 */}
|
||||||
{error && (
|
{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">
|
<div className="flex items-center space-x-2">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<span className="text-sm">템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
<span className="text-sm">템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
||||||
</div>
|
</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" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 템플릿 목록 */}
|
{/* 템플릿 목록 */}
|
||||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
|
@ -541,9 +551,10 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
</div>
|
</div>
|
||||||
) : filteredTemplates.length === 0 ? (
|
) : filteredTemplates.length === 0 ? (
|
||||||
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
||||||
<div>
|
<div className="p-8">
|
||||||
<FileText className="mx-auto mb-2 h-8 w-8" />
|
<FileText className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||||
<p className="text-sm">검색 결과가 없습니다</p>
|
<p className="text-sm font-medium text-gray-600">템플릿을 찾을 수 없습니다</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -551,27 +562,40 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, template)}
|
onDragStart={(e) => {
|
||||||
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, 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 items-start space-x-4">
|
||||||
<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 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}
|
{template.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
|
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{template.name}</h4>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<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}개
|
{template.components.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
|
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{template.description}</p>
|
||||||
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
|
<div className="flex items-center justify-between">
|
||||||
<span>
|
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||||
{template.defaultSize.width}×{template.defaultSize.height}
|
<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>
|
|
||||||
<span className="capitalize">{template.category}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -581,12 +605,14 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 도움말 */}
|
{/* 도움말 */}
|
||||||
<div className="rounded-lg bg-blue-50 p-3">
|
<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-2">
|
<div className="flex items-start space-x-3">
|
||||||
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||||
<div className="text-xs text-blue-700">
|
<Info className="h-4 w-4" />
|
||||||
<p className="mb-1 font-medium">사용 방법</p>
|
</div>
|
||||||
<p>템플릿을 캔버스로 드래그하여 빠르게 화면을 구성하세요.</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -504,6 +504,26 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
[component.id]: updatedFiles
|
[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) {
|
if (onUpdateComponent) {
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
|
|
@ -583,6 +603,54 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
[component.id]: filteredFiles
|
[component.id]: filteredFiles
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
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(() => {
|
||||||
|
try {
|
||||||
|
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 100ms)");
|
||||||
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: { ...eventDetail, delayed: true }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
console.log("🔄 FileUpload 위젯 추가 삭제 이벤트 발생 (지연 300ms)");
|
||||||
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FileUpload 지연 이벤트 발생 실패:", error);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("FileUpload 이벤트 발생 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
uploadedFiles: filteredFiles,
|
uploadedFiles: filteredFiles,
|
||||||
});
|
});
|
||||||
|
|
@ -635,8 +703,8 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
{/* 드래그 앤 드롭 영역 */}
|
{/* 드래그 앤 드롭 영역 */}
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
|
className={`rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300 ${
|
||||||
isDragOver ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-gray-400"
|
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}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|
@ -648,7 +716,7 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-4 text-sm text-gray-500">또는 클릭하여 파일을 선택하세요</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" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
{fileConfig.uploadButtonText || "파일 선택"}
|
{fileConfig.uploadButtonText || "파일 선택"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
||||||
<div className="h-full w-full space-y-2">
|
<div className="h-full w-full space-y-2">
|
||||||
{/* 파일 업로드 영역 */}
|
{/* 파일 업로드 영역 */}
|
||||||
<div
|
<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}
|
onClick={handleFileSelect}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,11 @@ function SelectContent({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal container={document.querySelector('[data-radix-portal]') || document.body}>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[99999] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[10000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ export const MESSAGES = {
|
||||||
CONFIRM: "정말로 진행하시겠습니까?",
|
CONFIRM: "정말로 진행하시겠습니까?",
|
||||||
NO_DATA: "데이터가 없습니다.",
|
NO_DATA: "데이터가 없습니다.",
|
||||||
NO_MENUS: "사용 가능한 메뉴가 없습니다.",
|
NO_MENUS: "사용 가능한 메뉴가 없습니다.",
|
||||||
|
FILE_SIZE_ERROR: "파일 크기가 너무 큽니다. 5MB 이하의 파일을 선택해주세요.",
|
||||||
|
FILE_TYPE_ERROR: "이미지 파일만 업로드 가능합니다.",
|
||||||
|
PROFILE_SAVE_ERROR: "프로필 저장 중 오류가 발생했습니다.",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MENU_ICONS = {
|
export const MENU_ICONS = {
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,107 @@
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import type { CodeFilter, CreateCodeData, UpdateCodeData } from "@/lib/schemas/commonCode";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
/**
|
// Query Keys
|
||||||
* 코드 목록 조회 훅
|
export const queryKeys = {
|
||||||
*/
|
codes: {
|
||||||
export function useCodes(categoryCode: string, filters?: CodeFilter) {
|
all: ["codes"] as const,
|
||||||
|
list: (categoryCode: string) => ["codes", "list", categoryCode] as const,
|
||||||
|
options: (categoryCode: string) => ["codes", "options", categoryCode] as const,
|
||||||
|
detail: (categoryCode: string, codeValue: string) =>
|
||||||
|
["codes", "detail", categoryCode, codeValue] as const,
|
||||||
|
infiniteList: (categoryCode: string, filters?: any) =>
|
||||||
|
["codes", "infiniteList", categoryCode, filters] as const,
|
||||||
|
},
|
||||||
|
tables: {
|
||||||
|
all: ["tables"] as const,
|
||||||
|
columns: (tableName: string) => ["tables", "columns", tableName] as const,
|
||||||
|
codeCategory: (tableName: string, columnName: string) =>
|
||||||
|
["tables", "codeCategory", tableName, columnName] as const,
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
all: ["categories"] as const,
|
||||||
|
list: (filters?: any) => ["categories", "list", filters] as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 컬럼의 코드 카테고리 조회
|
||||||
|
export function useTableCodeCategory(tableName?: string, columnName?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.codes.list(categoryCode, filters),
|
queryKey: queryKeys.tables.codeCategory(tableName || "", columnName || ""),
|
||||||
queryFn: () => commonCodeApi.codes.getList(categoryCode, filters),
|
queryFn: async () => {
|
||||||
select: (data) => data.data || [],
|
if (!tableName || !columnName) return null;
|
||||||
enabled: !!categoryCode, // categoryCode가 있을 때만 실행
|
|
||||||
|
console.log(`🔍 [React Query] 테이블 코드 카테고리 조회: ${tableName}.${columnName}`);
|
||||||
|
const columns = await tableTypeApi.getColumns(tableName);
|
||||||
|
const targetColumn = columns.find((col) => col.columnName === columnName);
|
||||||
|
|
||||||
|
const codeCategory = targetColumn?.codeCategory && targetColumn.codeCategory !== "none"
|
||||||
|
? targetColumn.codeCategory
|
||||||
|
: null;
|
||||||
|
|
||||||
|
console.log(`✅ [React Query] 테이블 코드 카테고리 결과: ${tableName}.${columnName} -> ${codeCategory}`);
|
||||||
|
return codeCategory;
|
||||||
|
},
|
||||||
|
enabled: !!(tableName && columnName),
|
||||||
|
staleTime: 10 * 60 * 1000, // 10분 캐시
|
||||||
|
gcTime: 30 * 60 * 1000, // 30분 가비지 컬렉션
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 코드 옵션 조회 (select용)
|
||||||
* 코드 생성 뮤테이션 훅
|
export function useCodeOptions(codeCategory?: string, enabled: boolean = true) {
|
||||||
*/
|
const query = useQuery({
|
||||||
|
queryKey: queryKeys.codes.options(codeCategory || ""),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!codeCategory || codeCategory === "none") return [];
|
||||||
|
|
||||||
|
console.log(`🔍 [React Query] 코드 옵션 조회: ${codeCategory}`);
|
||||||
|
const response = await commonCodeApi.codes.getList(codeCategory, { isActive: true });
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const options = response.data.map((code: any) => {
|
||||||
|
const actualValue = code.code || code.CODE || code.value || code.code_value || code.codeValue;
|
||||||
|
const actualLabel = code.codeName || code.code_name || code.name || code.CODE_NAME ||
|
||||||
|
code.NAME || code.label || code.LABEL || code.text || code.title ||
|
||||||
|
code.description || actualValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: actualValue,
|
||||||
|
label: actualLabel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ [React Query] 코드 옵션 결과: ${codeCategory} (${options.length}개)`);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
enabled: enabled && !!(codeCategory && codeCategory !== "none"),
|
||||||
|
staleTime: 10 * 60 * 1000, // 10분 캐시
|
||||||
|
gcTime: 30 * 60 * 1000, // 30분 가비지 컬렉션
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: query.data || [],
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
error: query.error,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 코드 생성
|
||||||
export function useCreateCode() {
|
export function useCreateCode() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: CreateCodeData }) =>
|
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: any }) =>
|
||||||
commonCodeApi.codes.create(categoryCode, data),
|
commonCodeApi.codes.create(categoryCode, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
|
// 해당 카테고리의 모든 코드 관련 쿼리 무효화
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.all,
|
queryKey: queryKeys.codes.all,
|
||||||
});
|
});
|
||||||
|
|
@ -34,15 +110,10 @@ export function useCreateCode() {
|
||||||
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
|
||||||
console.error("코드 생성 실패:", error);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 코드 수정
|
||||||
* 코드 수정 뮤테이션 훅
|
|
||||||
*/
|
|
||||||
export function useUpdateCode() {
|
export function useUpdateCode() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -54,14 +125,14 @@ export function useUpdateCode() {
|
||||||
}: {
|
}: {
|
||||||
categoryCode: string;
|
categoryCode: string;
|
||||||
codeValue: string;
|
codeValue: string;
|
||||||
data: UpdateCodeData;
|
data: any;
|
||||||
}) => commonCodeApi.codes.update(categoryCode, codeValue, data),
|
}) => commonCodeApi.codes.update(categoryCode, codeValue, data),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: (_, variables) => {
|
||||||
// 해당 코드 상세 쿼리 무효화
|
// 해당 코드 상세 쿼리 무효화
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
||||||
});
|
});
|
||||||
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤)
|
// 해당 카테고리의 모든 코드 관련 쿼리 무효화
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.codes.all,
|
queryKey: queryKeys.codes.all,
|
||||||
});
|
});
|
||||||
|
|
@ -70,15 +141,10 @@ export function useUpdateCode() {
|
||||||
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
|
||||||
console.error("코드 수정 실패:", error);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 코드 삭제
|
||||||
* 코드 삭제 뮤테이션 훅
|
|
||||||
*/
|
|
||||||
export function useDeleteCode() {
|
export function useDeleteCode() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -98,15 +164,10 @@ export function useDeleteCode() {
|
||||||
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
|
||||||
console.error("코드 삭제 실패:", error);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 코드 순서 변경
|
||||||
* 코드 순서 변경 뮤테이션 훅
|
|
||||||
*/
|
|
||||||
export function useReorderCodes() {
|
export function useReorderCodes() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -126,7 +187,7 @@ export function useReorderCodes() {
|
||||||
const previousCodes = queryClient.getQueryData(queryKeys.codes.list(categoryCode));
|
const previousCodes = queryClient.getQueryData(queryKeys.codes.list(categoryCode));
|
||||||
|
|
||||||
// Optimistic update: 새로운 순서로 즉시 업데이트
|
// Optimistic update: 새로운 순서로 즉시 업데이트
|
||||||
if (previousCodes && (previousCodes as any).data && Array.isArray((previousCodes as any).data)) {
|
if (previousCodes && Array.isArray((previousCodes as any).data)) {
|
||||||
const previousCodesArray = (previousCodes as any).data;
|
const previousCodesArray = (previousCodes as any).data;
|
||||||
|
|
||||||
// 기존 데이터를 복사하고 sort_order만 업데이트
|
// 기존 데이터를 복사하고 sort_order만 업데이트
|
||||||
|
|
@ -135,8 +196,8 @@ export function useReorderCodes() {
|
||||||
return newCodeData ? { ...code, sort_order: newCodeData.sortOrder } : code;
|
return newCodeData ? { ...code, sort_order: newCodeData.sortOrder } : code;
|
||||||
});
|
});
|
||||||
|
|
||||||
// sort_order로 정렬
|
// 순서대로 정렬
|
||||||
updatedCodes.sort((a: any, b: any) => a.sort_order - b.sort_order);
|
updatedCodes.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||||
|
|
||||||
// API 응답 형태로 캐시에 저장 (기존 구조 유지)
|
// API 응답 형태로 캐시에 저장 (기존 구조 유지)
|
||||||
queryClient.setQueryData(queryKeys.codes.list(categoryCode), {
|
queryClient.setQueryData(queryKeys.codes.list(categoryCode), {
|
||||||
|
|
@ -145,11 +206,9 @@ export function useReorderCodes() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 롤백용 데이터 반환
|
|
||||||
return { previousCodes };
|
return { previousCodes };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (err, variables, context) => {
|
||||||
console.error("코드 순서 변경 실패:", error);
|
|
||||||
// 에러 시 이전 데이터로 롤백
|
// 에러 시 이전 데이터로 롤백
|
||||||
if (context?.previousCodes) {
|
if (context?.previousCodes) {
|
||||||
queryClient.setQueryData(queryKeys.codes.list(variables.categoryCode), context.previousCodes);
|
queryClient.setQueryData(queryKeys.codes.list(variables.categoryCode), context.previousCodes);
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
positionName: "",
|
positionName: "",
|
||||||
locale: "",
|
locale: "",
|
||||||
},
|
},
|
||||||
selectedImage: "",
|
selectedImage: null,
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
});
|
});
|
||||||
|
|
@ -80,13 +80,6 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
*/
|
*/
|
||||||
const openProfileModal = useCallback(() => {
|
const openProfileModal = useCallback(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
console.log("🔍 프로필 모달 열기 - 사용자 정보:", {
|
|
||||||
userName: user.userName,
|
|
||||||
email: user.email,
|
|
||||||
deptName: user.deptName,
|
|
||||||
locale: user.locale,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 부서 목록 로드
|
// 부서 목록 로드
|
||||||
loadDepartments();
|
loadDepartments();
|
||||||
|
|
||||||
|
|
@ -100,7 +93,7 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
positionName: user.positionName || "",
|
positionName: user.positionName || "",
|
||||||
locale: user.locale || "KR", // 기본값을 KR로 설정
|
locale: user.locale || "KR", // 기본값을 KR로 설정
|
||||||
},
|
},
|
||||||
selectedImage: user.photo || "",
|
selectedImage: user.photo || null,
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
}));
|
}));
|
||||||
|
|
@ -113,6 +106,8 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
const closeProfileModal = useCallback(() => {
|
const closeProfileModal = useCallback(() => {
|
||||||
setModalState((prev) => ({
|
setModalState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
selectedImage: null,
|
||||||
|
selectedFile: null,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -173,17 +168,21 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
* 이미지 삭제
|
* 이미지 삭제
|
||||||
*/
|
*/
|
||||||
const removeImage = useCallback(() => {
|
const removeImage = useCallback(() => {
|
||||||
setModalState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
selectedImage: "",
|
|
||||||
selectedFile: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 파일 input 초기화
|
// 파일 input 초기화
|
||||||
const fileInput = document.getElementById("profile-image-input") as HTMLInputElement;
|
const fileInput = document.getElementById("profile-image-input") as HTMLInputElement;
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 상태 업데이트 - 명시적으로 null로 설정하여 AvatarFallback이 확실히 표시되도록 함
|
||||||
|
setModalState((prev) => {
|
||||||
|
const newState = {
|
||||||
|
...prev,
|
||||||
|
selectedImage: null, // 빈 문자열 대신 null로 설정
|
||||||
|
selectedFile: null,
|
||||||
|
};
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -195,8 +194,8 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
setModalState((prev) => ({ ...prev, isSaving: true }));
|
setModalState((prev) => ({ ...prev, isSaving: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 선택된 이미지가 있으면 Base64로 변환, 없으면 기존 이미지 유지
|
// 이미지 데이터 결정 로직
|
||||||
let photoData = user.photo || "";
|
let photoData: string | null | undefined = undefined;
|
||||||
|
|
||||||
if (modalState.selectedFile) {
|
if (modalState.selectedFile) {
|
||||||
// 새로 선택된 파일을 Base64로 변환
|
// 새로 선택된 파일을 Base64로 변환
|
||||||
|
|
@ -207,26 +206,29 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(modalState.selectedFile!);
|
reader.readAsDataURL(modalState.selectedFile!);
|
||||||
});
|
});
|
||||||
|
} else if (modalState.selectedImage === null || modalState.selectedImage === "") {
|
||||||
|
// 이미지가 명시적으로 삭제된 경우 (X 버튼 클릭)
|
||||||
|
photoData = null;
|
||||||
} else if (modalState.selectedImage && modalState.selectedImage !== user.photo) {
|
} else if (modalState.selectedImage && modalState.selectedImage !== user.photo) {
|
||||||
// 미리보기 이미지가 변경된 경우 사용
|
// 미리보기 이미지가 변경된 경우
|
||||||
photoData = modalState.selectedImage;
|
photoData = modalState.selectedImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 정보 저장 데이터 준비
|
// 사용자 정보 저장 데이터 준비
|
||||||
const updateData = {
|
const updateData: any = {
|
||||||
userName: modalState.formData.userName,
|
userName: modalState.formData.userName,
|
||||||
email: modalState.formData.email,
|
email: modalState.formData.email,
|
||||||
locale: modalState.formData.locale,
|
locale: modalState.formData.locale,
|
||||||
photo: photoData !== user.photo ? photoData : undefined, // 변경된 경우만 전송
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("프로필 업데이트 요청:", updateData);
|
// photo가 변경된 경우에만 추가 (undefined가 아닌 경우)
|
||||||
|
if (photoData !== undefined) {
|
||||||
|
updateData.photo = photoData;
|
||||||
|
}
|
||||||
|
|
||||||
// API 호출 (JWT 토큰 자동 포함)
|
// API 호출 (JWT 토큰 자동 포함)
|
||||||
const response = await apiCall("PUT", "/admin/profile", updateData);
|
const response = await apiCall("PUT", "/admin/profile", updateData);
|
||||||
|
|
||||||
console.log("프로필 업데이트 응답:", response);
|
|
||||||
|
|
||||||
if (response.success || (response as any).result) {
|
if (response.success || (response as any).result) {
|
||||||
// locale이 변경된 경우 전역 변수와 localStorage 업데이트
|
// locale이 변경된 경우 전역 변수와 localStorage 업데이트
|
||||||
const localeChanged = modalState.formData.locale && modalState.formData.locale !== user.locale;
|
const localeChanged = modalState.formData.locale && modalState.formData.locale !== user.locale;
|
||||||
|
|
@ -234,7 +236,6 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
// 🎯 useMultiLang의 콜백 시스템을 사용하여 모든 컴포넌트에 즉시 알림
|
// 🎯 useMultiLang의 콜백 시스템을 사용하여 모든 컴포넌트에 즉시 알림
|
||||||
const { notifyLanguageChange } = await import("@/hooks/useMultiLang");
|
const { notifyLanguageChange } = await import("@/hooks/useMultiLang");
|
||||||
notifyLanguageChange(modalState.formData.locale);
|
notifyLanguageChange(modalState.formData.locale);
|
||||||
console.log("🌍 사용자 locale 업데이트 (콜백 방식):", modalState.formData.locale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성공: 사용자 정보 새로고침
|
// 성공: 사용자 정보 새로고침
|
||||||
|
|
@ -242,15 +243,17 @@ export const useProfile = (user: any, refreshUserData: () => Promise<void>, refr
|
||||||
|
|
||||||
// locale이 변경된 경우 메뉴도 새로고침
|
// locale이 변경된 경우 메뉴도 새로고침
|
||||||
if (localeChanged && refreshMenus) {
|
if (localeChanged && refreshMenus) {
|
||||||
console.log("🔄 locale 변경으로 인한 메뉴 새로고침 시작");
|
|
||||||
await refreshMenus();
|
await refreshMenus();
|
||||||
console.log("✅ 메뉴 새로고침 완료");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모달 상태 초기화 (저장 후 즉시 반영을 위해)
|
||||||
setModalState((prev) => ({
|
setModalState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
|
selectedImage: null,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
showAlert("저장 완료", "프로필이 성공적으로 업데이트되었습니다.", "success");
|
showAlert("저장 완료", "프로필이 성공적으로 업데이트되었습니다.", "success");
|
||||||
} else {
|
} else {
|
||||||
throw new Error((response as any).message || "프로필 업데이트 실패");
|
throw new Error((response as any).message || "프로필 업데이트 실패");
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface FileUploadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
files: FileInfo[];
|
files: FileInfo[];
|
||||||
|
data?: FileInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileDownloadParams {
|
export interface FileDownloadParams {
|
||||||
|
|
@ -134,6 +135,28 @@ export const getFileInfo = async (fileId: string, serverFilename: string) => {
|
||||||
return response.data;
|
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 데이터 생성
|
* 파일 업로드 및 JSON 데이터 생성
|
||||||
* InteractiveScreenViewer에서 사용할 통합 함수
|
* InteractiveScreenViewer에서 사용할 통합 함수
|
||||||
|
|
|
||||||
|
|
@ -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[])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,14 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
|
// 모든 hooks를 먼저 호출 (조건부 return 이전에)
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
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(() => {
|
const webTypeDefinition = useMemo(() => {
|
||||||
return WebTypeRegistry.getWebType(webType);
|
return WebTypeRegistry.getWebType(webType);
|
||||||
}, [webType]);
|
}, [webType]);
|
||||||
|
|
@ -47,16 +55,27 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
};
|
};
|
||||||
}, [props, mergedConfig, webType, onEvent]);
|
}, [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에서 지정된 컴포넌트 사용 (항상 우선)
|
// 1순위: DB에서 지정된 컴포넌트 사용 (항상 우선)
|
||||||
if (dbWebType?.component_name) {
|
if (dbWebType?.component_name) {
|
||||||
try {
|
try {
|
||||||
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
|
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
|
||||||
console.log(`DB 웹타입 정보:`, dbWebType);
|
console.log("DB 웹타입 정보:", dbWebType);
|
||||||
|
|
||||||
// FileWidget의 경우 FileUploadComponent 직접 사용
|
// FileWidget의 경우 FileUploadComponent 직접 사용
|
||||||
if (dbWebType.component_name === "FileWidget" || webType === "file") {
|
if (dbWebType.component_name === "FileWidget" || webType === "file") {
|
||||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
console.log(`✅ FileWidget → FileUploadComponent 사용`);
|
console.log("✅ FileWidget → FileUploadComponent 사용");
|
||||||
return <FileUploadComponent {...props} {...finalProps} />;
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +84,9 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
||||||
// return <ComponentByName {...props} {...finalProps} />;
|
// return <ComponentByName {...props} {...finalProps} />;
|
||||||
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
|
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
|
||||||
return <div>컴포넌트 로딩 중...</div>;
|
|
||||||
|
// 로딩 중 메시지 대신 레지스트리로 폴백
|
||||||
|
// return <div>컴포넌트 로딩 중...</div>;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
|
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +99,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
|
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
|
||||||
if (webType === "file") {
|
if (webType === "file") {
|
||||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
console.log(`✅ 파일 웹타입 → FileUploadComponent 사용`);
|
console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
|
||||||
return <FileUploadComponent {...props} {...finalProps} />;
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,14 +131,35 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
|
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
|
||||||
if (webType === "file") {
|
if (webType === "file") {
|
||||||
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
console.log(`✅ 폴백: 파일 웹타입 → FileUploadComponent 사용`);
|
console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
|
||||||
return <FileUploadComponent {...props} {...finalProps} />;
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// const FallbackComponent = getWidgetComponentByWebType(webType);
|
// 텍스트 입력 웹타입들
|
||||||
// return <FallbackComponent {...props} />;
|
if (["text", "email", "password", "tel"].includes(webType)) {
|
||||||
console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`);
|
const { TextInputComponent } = require("@/lib/registry/components/text-input/TextInputComponent");
|
||||||
return <div>웹타입 로딩 중...</div>;
|
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) {
|
} catch (error) {
|
||||||
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
|
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -244,33 +244,39 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컨테이너 스타일 (원래 카드 레이아웃과 완전히 동일)
|
// 컨테이너 스타일 - 통일된 디자인 시스템 적용
|
||||||
const containerStyle: React.CSSProperties = {
|
const containerStyle: React.CSSProperties = {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
|
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
|
||||||
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
|
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
|
||||||
gap: `${componentConfig.cardSpacing || 16}px`,
|
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌
|
||||||
padding: "16px",
|
padding: "32px", // 패딩 대폭 증가
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
background: "transparent",
|
background: "linear-gradient(to br, #f8fafc, #f1f5f9)", // 부드러운 그라데이션 배경
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
|
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카드 스타일 (원래 카드 레이아웃과 완전히 동일)
|
// 카드 스타일 - 통일된 디자인 시스템 적용
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
border: "1px solid #e5e7eb",
|
border: "1px solid #e2e8f0", // 더 부드러운 보더 색상
|
||||||
borderRadius: "8px",
|
borderRadius: "12px", // 통일된 라운드 처리
|
||||||
padding: "16px",
|
padding: "24px", // 더 여유로운 패딩
|
||||||
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
|
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", // 더 깊은 그림자
|
||||||
transition: "all 0.2s ease-in-out",
|
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // 부드러운 트랜지션
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
minHeight: "200px",
|
minHeight: "240px", // 최소 높이 더 증가
|
||||||
cursor: isDesignMode ? "pointer" : "default",
|
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
|
<div
|
||||||
key={data.id || index}
|
key={data.id || index}
|
||||||
style={cardStyle}
|
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)}
|
onClick={() => handleCardClick(data)}
|
||||||
>
|
>
|
||||||
{/* 카드 이미지 */}
|
{/* 카드 이미지 - 통일된 디자인 */}
|
||||||
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
{componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && (
|
||||||
<div className="mb-3 flex justify-center">
|
<div className="mb-4 flex justify-center">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200">
|
<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-xl text-gray-500">👤</span>
|
<span className="text-2xl text-blue-600">👤</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 카드 타이틀 */}
|
{/* 카드 타이틀 - 통일된 디자인 */}
|
||||||
{componentConfig.cardStyle?.showTitle && (
|
{componentConfig.cardStyle?.showTitle && (
|
||||||
<div className="mb-2">
|
<div className="mb-3">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{titleValue}</h3>
|
<h3 className="text-xl font-bold text-gray-900 leading-tight">{titleValue}</h3>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 카드 서브타이틀 */}
|
{/* 카드 서브타이틀 - 통일된 디자인 */}
|
||||||
{componentConfig.cardStyle?.showSubtitle && (
|
{componentConfig.cardStyle?.showSubtitle && (
|
||||||
<div className="mb-2">
|
<div className="mb-3">
|
||||||
<p className="text-sm font-medium text-blue-600">{subtitleValue}</p>
|
<p className="text-sm font-semibold text-blue-600 bg-blue-50 px-3 py-1 rounded-full inline-block">{subtitleValue}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 카드 설명 */}
|
{/* 카드 설명 - 통일된 디자인 */}
|
||||||
{componentConfig.cardStyle?.showDescription && (
|
{componentConfig.cardStyle?.showDescription && (
|
||||||
<div className="mb-3 flex-1">
|
<div className="mb-4 flex-1">
|
||||||
<p className="text-sm leading-relaxed text-gray-600">
|
<p className="text-sm leading-relaxed text-gray-700 bg-gray-50 p-3 rounded-lg">
|
||||||
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 추가 표시 컬럼들 */}
|
{/* 추가 표시 컬럼들 - 통일된 디자인 */}
|
||||||
{componentConfig.columnMapping?.displayColumns &&
|
{componentConfig.columnMapping?.displayColumns &&
|
||||||
componentConfig.columnMapping.displayColumns.length > 0 && (
|
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) => {
|
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
|
||||||
const value = getColumnValue(data, columnName);
|
const value = getColumnValue(data, columnName);
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="flex justify-between text-xs">
|
<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-500 capitalize">{getColumnLabel(columnName)}:</span>
|
<span className="text-gray-600 font-medium capitalize">{getColumnLabel(columnName)}:</span>
|
||||||
<span className="font-medium text-gray-700">{value}</span>
|
<span className="font-semibold text-gray-900 bg-gray-100 px-2 py-1 rounded-md text-xs">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,9 +3,11 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { toast } from "sonner";
|
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 { formatFileSize } from "@/lib/utils";
|
||||||
import { FileViewerModal } from "./FileViewerModal";
|
import { FileViewerModal } from "./FileViewerModal";
|
||||||
|
import { FileManagerModal } from "./FileManagerModal";
|
||||||
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
|
|
@ -75,13 +77,13 @@ export interface FileUploadComponentProps {
|
||||||
onConfigChange?: (config: any) => void;
|
onConfigChange?: (config: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
component,
|
component,
|
||||||
componentConfig,
|
componentConfig,
|
||||||
componentStyle,
|
componentStyle,
|
||||||
className,
|
className,
|
||||||
isInteractive,
|
isInteractive,
|
||||||
isDesignMode,
|
isDesignMode = false, // 기본값 설정
|
||||||
formData,
|
formData,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -94,55 +96,305 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
||||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
|
const [isFileManagerOpen, setIsFileManagerOpen] = useState(false);
|
||||||
const [forceUpdate, setForceUpdate] = useState(0);
|
const [forceUpdate, setForceUpdate] = useState(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const componentFiles = (component as any)?.uploadedFiles || [];
|
const componentFiles = (component as any)?.uploadedFiles || [];
|
||||||
const lastUpdate = (component as any)?.lastFileUpdate;
|
const lastUpdate = (component as any)?.lastFileUpdate;
|
||||||
|
|
||||||
// 전역 상태에서 최신 파일 정보 가져오기
|
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
|
||||||
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,
|
componentId: component.id,
|
||||||
componentFiles: componentFiles.length,
|
componentFiles: componentFiles.length,
|
||||||
globalFiles: globalFiles.length,
|
formData: formData,
|
||||||
currentFiles: currentFiles.length,
|
screenId: formData?.screenId,
|
||||||
uploadedFiles: uploadedFiles.length,
|
currentUploadedFiles: uploadedFiles.length
|
||||||
lastUpdate: lastUpdate
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// localStorage에서 백업 파일 복원
|
// 먼저 새로운 템플릿 파일 조회 시도
|
||||||
try {
|
loadComponentFiles().then(useNewLogic => {
|
||||||
const backupKey = `fileUpload_${component.id}`;
|
if (useNewLogic) {
|
||||||
const backupFiles = localStorage.getItem(backupKey);
|
console.log("✅ 새로운 템플릿 파일 로직 사용");
|
||||||
if (backupFiles && currentFiles.length === 0) {
|
return; // 새로운 로직이 성공했으면 기존 로직 스킵
|
||||||
const parsedFiles = JSON.parse(backupFiles);
|
|
||||||
setUploadedFiles(parsedFiles);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.warn("localStorage 백업 복원 실패:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최신 파일과 현재 파일 비교
|
// 기존 로직 사용
|
||||||
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
console.log("📂 기존 파일 로직 사용");
|
||||||
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
|
|
||||||
|
// 전역 상태에서 최신 파일 정보 가져오기
|
||||||
|
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,
|
currentFiles: currentFiles.length,
|
||||||
uploadedFiles: uploadedFiles.length,
|
uploadedFiles: uploadedFiles.length,
|
||||||
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
lastUpdate: lastUpdate
|
||||||
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
|
||||||
});
|
});
|
||||||
setUploadedFiles(currentFiles);
|
|
||||||
setForceUpdate(prev => prev + 1);
|
// localStorage에서 백업 파일 복원 (새로고침 시 중요!)
|
||||||
}
|
try {
|
||||||
}, [component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -164,9 +416,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
|
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
|
||||||
console.log(logMessage, {
|
console.log(logMessage, {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
이전파일수: uploadedFiles.length,
|
이전파일수: uploadedFiles?.length || 0,
|
||||||
새파일수: files.length,
|
새파일수: files?.length || 0,
|
||||||
files: files.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || []
|
||||||
});
|
});
|
||||||
|
|
||||||
setUploadedFiles(files);
|
setUploadedFiles(files);
|
||||||
|
|
@ -203,8 +455,18 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
// 파일 선택 핸들러
|
// 파일 선택 핸들러
|
||||||
const handleFileSelect = useCallback(() => {
|
const handleFileSelect = useCallback(() => {
|
||||||
|
console.log("🎯 handleFileSelect 호출됨:", {
|
||||||
|
hasFileInputRef: !!fileInputRef.current,
|
||||||
|
fileInputRef: fileInputRef.current,
|
||||||
|
fileInputType: fileInputRef.current?.type,
|
||||||
|
fileInputHidden: fileInputRef.current?.className
|
||||||
|
});
|
||||||
|
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
|
console.log("✅ fileInputRef.current.click() 호출");
|
||||||
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' });
|
toast.loading("파일을 업로드하는 중...", { id: 'file-upload' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// targetObjid 생성 (InteractiveDataTable과 호환)
|
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
|
||||||
const tableName = formData?.tableName || component.tableName || 'default_table';
|
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 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 = {
|
const uploadData = {
|
||||||
tableName: tableName,
|
// 🎯 formData에서 백엔드 API 설정 가져오기
|
||||||
fieldName: columnName,
|
autoLink: formData?.autoLink || true,
|
||||||
recordId: recordId,
|
linkedTable: formData?.linkedTable || tableName,
|
||||||
|
recordId: formData?.recordId || recordId || `temp_${component.id}`,
|
||||||
|
columnName: formData?.columnName || columnName,
|
||||||
|
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
||||||
docType: component.fileConfig?.docType || 'DOCUMENT',
|
docType: component.fileConfig?.docType || 'DOCUMENT',
|
||||||
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
|
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
|
||||||
|
// 호환성을 위한 기존 필드들
|
||||||
|
tableName: tableName,
|
||||||
|
fieldName: columnName,
|
||||||
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||||
columnName: columnName, // 가상 파일 컬럼 지원
|
|
||||||
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📤 파일 업로드 시작:", {
|
console.log("📤 파일 업로드 시작:", {
|
||||||
|
|
@ -358,6 +639,13 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
globalFileState[component.id] = updatedFiles;
|
globalFileState[component.id] = updatedFiles;
|
||||||
(window as any).globalFileState = globalFileState;
|
(window as any).globalFileState = globalFileState;
|
||||||
|
|
||||||
|
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
||||||
|
GlobalFileManager.registerFiles(newFiles, {
|
||||||
|
uploadPage: window.location.pathname,
|
||||||
|
componentId: component.id,
|
||||||
|
screenId: formData?.screenId,
|
||||||
|
});
|
||||||
|
|
||||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||||
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -429,6 +717,11 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
if (safeComponentConfig.onFileUpload) {
|
if (safeComponentConfig.onFileUpload) {
|
||||||
safeComponentConfig.onFileUpload(newFiles);
|
safeComponentConfig.onFileUpload(newFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 성공 시 토스트 처리
|
||||||
|
setUploadStatus('idle');
|
||||||
|
toast.dismiss('file-upload');
|
||||||
|
toast.success(`${newFiles.length}개 파일 업로드 완료`);
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 파일 업로드 실패:", response);
|
console.error("❌ 파일 업로드 실패:", response);
|
||||||
throw new Error(response.message || (response as any).error || '파일 업로드에 실패했습니다.');
|
throw new Error(response.message || (response as any).error || '파일 업로드에 실패했습니다.');
|
||||||
|
|
@ -436,15 +729,31 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 업로드 오류:', error);
|
console.error('파일 업로드 오류:', error);
|
||||||
setUploadStatus('error');
|
setUploadStatus('error');
|
||||||
toast.dismiss();
|
toast.dismiss('file-upload');
|
||||||
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||||
}
|
}
|
||||||
}, [safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData]);
|
}, [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) => {
|
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
||||||
try {
|
try {
|
||||||
await downloadFile(file.objid, file.realFileName);
|
await downloadFile({
|
||||||
|
fileId: file.objid,
|
||||||
|
serverFilename: file.savedFileName,
|
||||||
|
originalName: file.realFileName
|
||||||
|
});
|
||||||
toast.success(`${file.realFileName} 다운로드 완료`);
|
toast.success(`${file.realFileName} 다운로드 완료`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('파일 다운로드 오류:', error);
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
|
@ -458,7 +767,8 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
const fileId = typeof file === 'string' ? file : file.objid;
|
const fileId = typeof file === 'string' ? file : file.objid;
|
||||||
const fileName = typeof file === 'string' ? '파일' : file.realFileName;
|
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);
|
const updatedFiles = uploadedFiles.filter(f => f.objid !== fileId);
|
||||||
setUploadedFiles(updatedFiles);
|
setUploadedFiles(updatedFiles);
|
||||||
|
|
@ -484,7 +794,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
files: updatedFiles,
|
files: updatedFiles,
|
||||||
fileCount: updatedFiles.length,
|
fileCount: updatedFiles.length,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now(),
|
||||||
|
source: 'realScreen', // 🎯 실제 화면에서 온 이벤트임을 표시
|
||||||
|
action: 'delete'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.dispatchEvent(syncEvent);
|
window.dispatchEvent(syncEvent);
|
||||||
|
|
@ -512,25 +824,23 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [uploadedFiles, onUpdate, component.id]);
|
}, [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) => {
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
console.log("🎯 드래그 오버 이벤트 감지:", {
|
||||||
|
readonly: safeComponentConfig.readonly,
|
||||||
|
disabled: safeComponentConfig.disabled,
|
||||||
|
dragOver: dragOver
|
||||||
|
});
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
||||||
setDragOver(true);
|
setDragOver(true);
|
||||||
|
console.log("✅ 드래그 오버 활성화");
|
||||||
|
} else {
|
||||||
|
console.log("❌ 드래그 차단됨: readonly 또는 disabled");
|
||||||
}
|
}
|
||||||
}, [safeComponentConfig.readonly, safeComponentConfig.disabled]);
|
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver]);
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -553,27 +863,53 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
|
|
||||||
// 클릭 핸들러
|
// 클릭 핸들러
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
console.log("🖱️ 파일 업로드 영역 클릭:", {
|
||||||
|
readonly: safeComponentConfig.readonly,
|
||||||
|
disabled: safeComponentConfig.disabled,
|
||||||
|
hasHandleFileSelect: !!handleFileSelect
|
||||||
|
});
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
||||||
|
console.log("✅ 파일 선택 함수 호출");
|
||||||
handleFileSelect();
|
handleFileSelect();
|
||||||
|
} else {
|
||||||
|
console.log("❌ 클릭 차단됨: readonly 또는 disabled");
|
||||||
}
|
}
|
||||||
onClick?.();
|
onClick?.();
|
||||||
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick]);
|
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className}>
|
<div
|
||||||
{/* 라벨 렌더링 */}
|
style={{
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
...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
|
<label
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "-25px",
|
top: "-20px",
|
||||||
left: "0px",
|
left: "0px",
|
||||||
fontSize: component.style?.labelFontSize || "14px",
|
fontSize: "12px",
|
||||||
color: component.style?.labelColor || "#3b83f6",
|
color: "rgb(107, 114, 128)",
|
||||||
fontWeight: "500",
|
fontWeight: "400",
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
background: "transparent !important",
|
||||||
|
border: "none !important",
|
||||||
|
boxShadow: "none !important",
|
||||||
|
outline: "none !important",
|
||||||
|
padding: "0px !important",
|
||||||
|
margin: "0px !important"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.label}
|
{component.label}
|
||||||
|
|
@ -581,18 +917,22 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
<span style={{ color: "#ef4444" }}>*</span>
|
<span style={{ color: "#ef4444" }}>*</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
<div className="w-full h-full flex flex-col space-y-2">
|
<div
|
||||||
{/* 디자인 모드가 아닐 때만 파일 업로드 영역 표시 */}
|
className="w-full h-full flex flex-col space-y-2"
|
||||||
{!isDesignMode && (
|
style={{ minHeight: '120px' }}
|
||||||
|
>
|
||||||
|
{/* 파일 업로드 영역 - 주석처리 */}
|
||||||
|
{/* {!isDesignMode && (
|
||||||
<div
|
<div
|
||||||
className={`
|
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'}
|
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
||||||
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
||||||
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
|
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
|
||||||
`}
|
`}
|
||||||
|
style={{ minHeight: '50px' }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|
@ -603,9 +943,9 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple={safeComponentConfig.multiple}
|
multiple={safeComponentConfig.multiple}
|
||||||
accept={safeComponentConfig.accept}
|
accept={safeComponentConfig.accept}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={safeComponentConfig.disabled}
|
disabled={safeComponentConfig.disabled}
|
||||||
/>
|
/>
|
||||||
|
|
@ -620,60 +960,82 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
<Upload className="mx-auto h-6 w-6 text-gray-400 mb-2" />
|
||||||
<p className="text-lg font-medium text-gray-900 mb-2">
|
<p className="text-xs font-medium text-gray-600">
|
||||||
{safeComponentConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요"}
|
파일 업로드
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{safeComponentConfig.accept && `지원 형식: ${safeComponentConfig.accept}`}
|
|
||||||
{safeComponentConfig.maxSize && ` • 최대 ${formatFileSize(safeComponentConfig.maxSize)}`}
|
|
||||||
{safeComponentConfig.multiple && ' • 여러 파일 선택 가능'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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="flex-1 overflow-y-auto">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<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})
|
업로드된 파일 ({uploadedFiles.length})
|
||||||
</h4>
|
</h4>
|
||||||
{uploadedFiles.length > 0 && (
|
<div className="flex items-center space-x-2">
|
||||||
<Badge variant="secondary" className="text-xs">
|
{uploadedFiles.length > 0 && (
|
||||||
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
<Badge variant="secondary" className="text-xs">
|
||||||
</Badge>
|
총 {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>
|
</div>
|
||||||
|
|
||||||
{uploadedFiles.length > 0 ? (
|
{uploadedFiles.length > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{uploadedFiles.map((file) => (
|
{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">
|
<div className="flex-shrink-0">
|
||||||
{getFileIcon(file.fileExt)}
|
{getFileIcon(file.fileExt)}
|
||||||
</div>
|
</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}
|
{file.realFileName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500" style={{ textShadow: 'none' }}>
|
||||||
{formatFileSize(file.fileSize)}
|
{formatFileSize(file.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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" />
|
<File className="w-12 h-12 mb-3 text-gray-300" />
|
||||||
<p className="text-sm font-medium">업로드된 파일이 없습니다</p>
|
<p className="text-sm font-medium" style={{ textShadow: 'none' }}>업로드된 파일이 없습니다</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">상세설정에서 파일을 업로드하세요</p>
|
<p className="text-xs text-gray-400 mt-1" style={{ textShadow: 'none' }}>상세설정에서 파일을 업로드하세요</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -694,6 +1056,20 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
isOpen={isViewerOpen}
|
isOpen={isViewerOpen}
|
||||||
onClose={handleViewerClose}
|
onClose={handleViewerClose}
|
||||||
onDownload={handleFileDownload}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,176 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { FileInfo } from "./types";
|
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 { 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 {
|
interface FileViewerModalProps {
|
||||||
file: FileInfo | null;
|
file: FileInfo | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDownload?: (file: FileInfo) => void;
|
onDownload?: (file: FileInfo) => void;
|
||||||
|
onDelete?: (file: FileInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 뷰어 모달 컴포넌트
|
* 파일 뷰어 모달 컴포넌트
|
||||||
* 다양한 파일 타입에 대한 미리보기 기능 제공
|
|
||||||
*/
|
*/
|
||||||
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
file,
|
file,
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 생성
|
// 파일이 변경될 때마다 미리보기 URL 생성
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!file || !isOpen) {
|
if (!file || !isOpen) {
|
||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setPreviewError(null);
|
setPreviewError(null);
|
||||||
|
setRenderedContent(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,16 +186,18 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
// 서버 파일인 경우 - 미리보기 API 호출
|
// 서버 파일인 경우 - 미리보기 API 호출
|
||||||
const generatePreviewUrl = async () => {
|
const generatePreviewUrl = async () => {
|
||||||
try {
|
try {
|
||||||
const fileExt = file.fileExt.toLowerCase();
|
const fileExt = file.fileExt.toLowerCase();
|
||||||
|
|
||||||
// 미리보기 지원 파일 타입 정의
|
// 미리보기 지원 파일 타입 정의
|
||||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
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 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 textExtensions = ["txt", "md", "json", "xml", "csv"];
|
||||||
const mediaExtensions = ['mp4', 'webm', 'ogg', 'mp3', 'wav'];
|
const mediaExtensions = ["mp4", "webm", "ogg", "mp3", "wav"];
|
||||||
|
|
||||||
const supportedExtensions = [
|
const supportedExtensions = [
|
||||||
...imageExtensions,
|
...imageExtensions,
|
||||||
|
|
@ -68,22 +207,97 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
];
|
];
|
||||||
|
|
||||||
if (supportedExtensions.includes(fileExt)) {
|
if (supportedExtensions.includes(fileExt)) {
|
||||||
// 실제 환경에서는 파일 서빙 API 엔드포인트 사용
|
// 이미지나 PDF는 인증된 요청으로 Blob 생성
|
||||||
const url = `/api/files/preview/${file.objid}`;
|
if (imageExtensions.includes(fileExt) || fileExt === "pdf") {
|
||||||
setPreviewUrl(url);
|
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 {
|
} else {
|
||||||
// 지원하지 않는 파일 타입
|
// 지원하지 않는 파일 타입
|
||||||
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
|
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('미리보기 URL 생성 오류:', error);
|
console.error("미리보기 URL 생성 오류:", error);
|
||||||
setPreviewError('미리보기를 불러오는데 실패했습니다.');
|
setPreviewError("미리보기를 불러오는데 실패했습니다.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
generatePreviewUrl();
|
generatePreviewUrl();
|
||||||
|
|
||||||
|
// cleanup 함수 반환
|
||||||
|
return () => {
|
||||||
|
if (cleanup) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [file, isOpen]);
|
}, [file, isOpen]);
|
||||||
|
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
@ -100,8 +314,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
|
|
||||||
if (previewError) {
|
if (previewError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
|
<div className="flex flex-col items-center justify-center h-96">
|
||||||
<AlertTriangle className="w-16 h-16 mb-4" />
|
<AlertTriangle className="w-16 h-16 mb-4 text-yellow-500" />
|
||||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||||
<p className="text-sm text-center">{previewError}</p>
|
<p className="text-sm text-center">{previewError}</p>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -119,121 +333,163 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
const fileExt = file.fileExt.toLowerCase();
|
const fileExt = file.fileExt.toLowerCase();
|
||||||
|
|
||||||
// 이미지 파일
|
// 이미지 파일
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(fileExt)) {
|
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExt)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center max-h-96 overflow-hidden">
|
<div className="flex items-center justify-center max-h-96 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={previewUrl || ''}
|
src={previewUrl || ""}
|
||||||
alt={file.realFileName}
|
alt={file.realFileName}
|
||||||
className="max-w-full max-h-full object-contain rounded-lg"
|
className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
|
||||||
onError={() => setPreviewError('이미지를 불러올 수 없습니다.')}
|
onError={(e) => {
|
||||||
|
console.error("이미지 로드 오류:", previewUrl, e);
|
||||||
|
setPreviewError("이미지를 불러올 수 없습니다. 파일이 손상되었거나 서버에서 접근할 수 없습니다.");
|
||||||
|
}}
|
||||||
|
onLoad={() => {
|
||||||
|
console.log("이미지 로드 성공:", previewUrl);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 텍스트 파일
|
// 텍스트 파일
|
||||||
if (['txt', 'md', 'json', 'xml', 'csv'].includes(fileExt)) {
|
if (["txt", "md", "json", "xml", "csv"].includes(fileExt)) {
|
||||||
return (
|
return (
|
||||||
<div className="h-96 overflow-auto">
|
<div className="h-96 overflow-auto">
|
||||||
<iframe
|
<iframe
|
||||||
src={previewUrl || ''}
|
src={previewUrl || ""}
|
||||||
className="w-full h-full border rounded-lg"
|
className="w-full h-full border rounded-lg"
|
||||||
title={`${file.realFileName} 미리보기`}
|
onError={() => setPreviewError("텍스트 파일을 불러올 수 없습니다.")}
|
||||||
onError={() => setPreviewError('텍스트 파일을 불러올 수 없습니다.')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF 파일
|
// PDF 파일
|
||||||
if (fileExt === 'pdf') {
|
if (fileExt === "pdf") {
|
||||||
return (
|
return (
|
||||||
<div className="h-96">
|
<div className="h-96 overflow-auto">
|
||||||
<iframe
|
<iframe
|
||||||
src={previewUrl || ''}
|
src={previewUrl || ""}
|
||||||
className="w-full h-full border rounded-lg"
|
className="w-full h-full border rounded-lg"
|
||||||
title={`${file.realFileName} 미리보기`}
|
onError={() => setPreviewError("PDF 파일을 불러올 수 없습니다.")}
|
||||||
onError={() => setPreviewError('PDF 파일을 불러올 수 없습니다.')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Microsoft Office, 한컴오피스, Apple iWork 문서 파일
|
// Office 문서 (CDN 라이브러리 렌더링 또는 iframe)
|
||||||
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'hwpml', 'hcdt', 'hpt', 'pages', 'numbers', 'keynote'].includes(fileExt)) {
|
if (
|
||||||
// Office 파일은 Google Docs Viewer 또는 Microsoft Office Online을 통해 미리보기
|
["doc", "docx", "xls", "xlsx", "ppt", "pptx", "hwp", "hwpx", "hwpml", "hcdt", "hpt", "pages", "numbers", "keynote"].includes(fileExt)
|
||||||
const officeViewerUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(previewUrl || '')}`;
|
) {
|
||||||
|
// CDN 라이브러리로 렌더링된 콘텐츠가 있는 경우
|
||||||
return (
|
if (renderedContent) {
|
||||||
<div className="h-96">
|
return (
|
||||||
<iframe
|
<div className="relative h-96 overflow-auto">
|
||||||
src={officeViewerUrl}
|
<div
|
||||||
className="w-full h-full border rounded-lg"
|
className="w-full h-full p-4 border rounded-lg bg-white"
|
||||||
title={`${file.realFileName} 미리보기`}
|
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
);
|
||||||
variant="outline"
|
}
|
||||||
onClick={() => onDownload?.(file)}
|
|
||||||
className="mt-4"
|
// iframe 방식 (fallback)
|
||||||
>
|
return (
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<div className="relative h-96 overflow-auto">
|
||||||
파일 다운로드
|
<iframe
|
||||||
</Button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비디오 파일
|
// 비디오 파일
|
||||||
if (['mp4', 'webm', 'ogg'].includes(fileExt)) {
|
if (["mp4", "webm", "ogg"].includes(fileExt)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
className="max-w-full max-h-96 rounded-lg"
|
className="w-full max-h-96"
|
||||||
onError={() => setPreviewError('비디오를 재생할 수 없습니다.')}
|
onError={() => setPreviewError("비디오를 재생할 수 없습니다.")}
|
||||||
>
|
>
|
||||||
<source src={previewUrl || ''} type={`video/${fileExt}`} />
|
<source src={previewUrl || ""} type={`video/${fileExt}`} />
|
||||||
브라우저가 비디오 재생을 지원하지 않습니다.
|
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 오디오 파일
|
// 오디오 파일
|
||||||
if (['mp3', 'wav', 'ogg'].includes(fileExt)) {
|
if (["mp3", "wav", "ogg"].includes(fileExt)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96">
|
<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">
|
<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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
className="w-full max-w-md"
|
className="w-full max-w-md"
|
||||||
onError={() => setPreviewError('오디오를 재생할 수 없습니다.')}
|
onError={() => setPreviewError("오디오를 재생할 수 없습니다.")}
|
||||||
>
|
>
|
||||||
<source src={previewUrl || ''} type={`audio/${fileExt}`} />
|
<source src={previewUrl || ""} type={`audio/${fileExt}`} />
|
||||||
브라우저가 오디오 재생을 지원하지 않습니다.
|
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -241,8 +497,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
|
|
||||||
// 기타 파일 타입
|
// 기타 파일 타입
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
|
<div className="flex flex-col items-center justify-center h-96">
|
||||||
<FileText className="w-16 h-16 mb-4" />
|
<FileText className="w-16 h-16 mb-4 text-gray-400" />
|
||||||
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||||
<p className="text-sm text-center mb-4">
|
<p className="text-sm text-center mb-4">
|
||||||
{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.
|
{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.
|
||||||
|
|
@ -259,8 +515,8 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto [&>button]:hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
|
|
@ -271,39 +527,53 @@ export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
{file.fileExt.toUpperCase()}
|
{file.fileExt.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
파일 크기: {formatFileSize(file.size)} | 파일 형식: {file.fileExt.toUpperCase()}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 파일 미리보기 영역 */}
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex-1 overflow-auto py-4">
|
|
||||||
{renderPreview()}
|
{renderPreview()}
|
||||||
</div>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ export interface FileInfo {
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
fileExt: string;
|
fileExt: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
docType: string;
|
docType?: string;
|
||||||
docTypeName: string;
|
docTypeName?: string;
|
||||||
targetObjid: string;
|
targetObjid: string;
|
||||||
parentTargetObjid?: string;
|
parentTargetObjid?: string;
|
||||||
companyCode: string;
|
companyCode?: string;
|
||||||
writer: string;
|
writer?: string;
|
||||||
regdate: string;
|
regdate?: string;
|
||||||
status: string;
|
status?: string;
|
||||||
|
|
||||||
// 추가 호환성 속성들
|
// 추가 호환성 속성들
|
||||||
path?: string; // filePath와 동일
|
path?: string; // filePath와 동일
|
||||||
|
|
@ -97,6 +97,7 @@ export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
|
||||||
export interface FileUploadResponse {
|
export interface FileUploadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: FileInfo[];
|
data?: FileInfo[];
|
||||||
|
files?: FileInfo[];
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { commonCodeApi } from "../../../api/commonCode";
|
|
||||||
import { tableTypeApi } from "../../../api/screen";
|
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -26,210 +25,10 @@ export interface SelectBasicComponentProps {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 전역 상태 관리: 모든 컴포넌트가 공유하는 상태
|
// ✅ React Query를 사용하여 중복 요청 방지 및 자동 캐싱 처리
|
||||||
interface GlobalState {
|
// - 동일한 queryKey에 대해서는 자동으로 중복 요청 제거
|
||||||
tableCategories: Map<string, string>; // tableName.columnName -> codeCategory
|
// - 10분 staleTime으로 적절한 캐시 관리
|
||||||
codeOptions: Map<string, { options: Option[]; timestamp: number }>; // codeCategory -> options
|
// - 30분 gcTime으로 메모리 효율성 확보
|
||||||
activeRequests: Map<string, Promise<any>>; // 진행 중인 요청들
|
|
||||||
subscribers: Set<() => void>; // 상태 변경 구독자들
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalState: GlobalState = {
|
|
||||||
tableCategories: new Map(),
|
|
||||||
codeOptions: new Map(),
|
|
||||||
activeRequests: new Map(),
|
|
||||||
subscribers: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 전역 상태 변경 알림
|
|
||||||
const notifyStateChange = () => {
|
|
||||||
globalState.subscribers.forEach((callback) => callback());
|
|
||||||
};
|
|
||||||
|
|
||||||
// 캐시 유효 시간 (5분)
|
|
||||||
const CACHE_DURATION = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// 🔧 전역 테이블 코드 카테고리 로딩 (중복 방지)
|
|
||||||
const loadGlobalTableCodeCategory = async (tableName: string, columnName: string): Promise<string | null> => {
|
|
||||||
const key = `${tableName}.${columnName}`;
|
|
||||||
|
|
||||||
// 이미 진행 중인 요청이 있으면 대기
|
|
||||||
if (globalState.activeRequests.has(`table_${key}`)) {
|
|
||||||
try {
|
|
||||||
await globalState.activeRequests.get(`table_${key}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 테이블 설정 로딩 대기 중 오류:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 캐시된 값이 있으면 반환
|
|
||||||
if (globalState.tableCategories.has(key)) {
|
|
||||||
const cachedCategory = globalState.tableCategories.get(key);
|
|
||||||
console.log(`✅ 캐시된 테이블 설정 사용: ${key} -> ${cachedCategory}`);
|
|
||||||
return cachedCategory || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새로운 요청 생성
|
|
||||||
const request = (async () => {
|
|
||||||
try {
|
|
||||||
console.log(`🔍 테이블 코드 카테고리 조회: ${key}`);
|
|
||||||
const columns = await tableTypeApi.getColumns(tableName);
|
|
||||||
const targetColumn = columns.find((col) => col.columnName === columnName);
|
|
||||||
|
|
||||||
const codeCategory =
|
|
||||||
targetColumn?.codeCategory && targetColumn.codeCategory !== "none" ? targetColumn.codeCategory : null;
|
|
||||||
|
|
||||||
// 전역 상태에 저장
|
|
||||||
globalState.tableCategories.set(key, codeCategory || "");
|
|
||||||
|
|
||||||
console.log(`✅ 테이블 설정 조회 완료: ${key} -> ${codeCategory}`);
|
|
||||||
|
|
||||||
// 상태 변경 알림
|
|
||||||
notifyStateChange();
|
|
||||||
|
|
||||||
return codeCategory;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ 테이블 코드 카테고리 조회 실패: ${key}`, error);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
globalState.activeRequests.delete(`table_${key}`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
globalState.activeRequests.set(`table_${key}`, request);
|
|
||||||
return request;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🔧 전역 코드 옵션 로딩 (중복 방지)
|
|
||||||
const loadGlobalCodeOptions = async (codeCategory: string): Promise<Option[]> => {
|
|
||||||
if (!codeCategory || codeCategory === "none") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 진행 중인 요청이 있으면 대기
|
|
||||||
if (globalState.activeRequests.has(`code_${codeCategory}`)) {
|
|
||||||
try {
|
|
||||||
await globalState.activeRequests.get(`code_${codeCategory}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 코드 옵션 로딩 대기 중 오류:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 캐시된 값이 유효하면 반환
|
|
||||||
const cached = globalState.codeOptions.get(codeCategory);
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
|
||||||
console.log(`✅ 캐시된 코드 옵션 사용: ${codeCategory} (${cached.options.length}개)`);
|
|
||||||
return cached.options;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새로운 요청 생성
|
|
||||||
const request = (async () => {
|
|
||||||
try {
|
|
||||||
console.log(`🔄 코드 옵션 로딩: ${codeCategory}`);
|
|
||||||
const response = await commonCodeApi.codes.getList(codeCategory, { isActive: true });
|
|
||||||
|
|
||||||
console.log(`🔍 [API 응답 원본] ${codeCategory}:`, {
|
|
||||||
response,
|
|
||||||
success: response.success,
|
|
||||||
data: response.data,
|
|
||||||
dataType: typeof response.data,
|
|
||||||
isArray: Array.isArray(response.data),
|
|
||||||
dataLength: response.data?.length,
|
|
||||||
firstItem: response.data?.[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
const options = response.data.map((code: any, index: number) => {
|
|
||||||
console.log(`🔍 [코드 매핑] ${index}:`, {
|
|
||||||
originalCode: code,
|
|
||||||
codeKeys: Object.keys(code),
|
|
||||||
values: Object.values(code),
|
|
||||||
// 가능한 모든 필드 확인
|
|
||||||
code: code.code,
|
|
||||||
codeName: code.codeName,
|
|
||||||
name: code.name,
|
|
||||||
label: code.label,
|
|
||||||
// 대문자 버전
|
|
||||||
CODE: code.CODE,
|
|
||||||
CODE_NAME: code.CODE_NAME,
|
|
||||||
NAME: code.NAME,
|
|
||||||
LABEL: code.LABEL,
|
|
||||||
// 스네이크 케이스
|
|
||||||
code_name: code.code_name,
|
|
||||||
code_value: code.code_value,
|
|
||||||
// 기타 가능한 필드들
|
|
||||||
value: code.value,
|
|
||||||
text: code.text,
|
|
||||||
title: code.title,
|
|
||||||
description: code.description,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 실제 값 찾기 시도 (우선순위 순)
|
|
||||||
const actualValue = code.code || code.CODE || code.value || code.code_value || `code_${index}`;
|
|
||||||
const actualLabel =
|
|
||||||
code.codeName ||
|
|
||||||
code.code_name || // 스네이크 케이스 추가!
|
|
||||||
code.name ||
|
|
||||||
code.CODE_NAME ||
|
|
||||||
code.NAME ||
|
|
||||||
code.label ||
|
|
||||||
code.LABEL ||
|
|
||||||
code.text ||
|
|
||||||
code.title ||
|
|
||||||
code.description ||
|
|
||||||
actualValue;
|
|
||||||
|
|
||||||
console.log(`✨ [최종 매핑] ${index}:`, {
|
|
||||||
actualValue,
|
|
||||||
actualLabel,
|
|
||||||
hasValue: !!actualValue,
|
|
||||||
hasLabel: !!actualLabel,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: actualValue,
|
|
||||||
label: actualLabel,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`🔍 [최종 옵션 배열] ${codeCategory}:`, {
|
|
||||||
optionsLength: options.length,
|
|
||||||
options: options.map((opt, idx) => ({
|
|
||||||
index: idx,
|
|
||||||
value: opt.value,
|
|
||||||
label: opt.label,
|
|
||||||
hasLabel: !!opt.label,
|
|
||||||
hasValue: !!opt.value,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전역 상태에 저장
|
|
||||||
globalState.codeOptions.set(codeCategory, {
|
|
||||||
options,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`✅ 코드 옵션 로딩 완료: ${codeCategory} (${options.length}개)`);
|
|
||||||
|
|
||||||
// 상태 변경 알림
|
|
||||||
notifyStateChange();
|
|
||||||
|
|
||||||
return options;
|
|
||||||
} else {
|
|
||||||
console.log(`⚠️ 빈 응답: ${codeCategory}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ 코드 옵션 로딩 실패: ${codeCategory}`, error);
|
|
||||||
return [];
|
|
||||||
} finally {
|
|
||||||
globalState.activeRequests.delete(`code_${codeCategory}`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
globalState.activeRequests.set(`code_${codeCategory}`, request);
|
|
||||||
return request;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
component,
|
component,
|
||||||
|
|
@ -248,6 +47,17 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
value: externalValue, // 명시적으로 value prop 받기
|
value: externalValue, // 명시적으로 value prop 받기
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
// 🚨 최우선 디버깅: 컴포넌트가 실행되는지 확인
|
||||||
|
console.log("🚨🚨🚨 SelectBasicComponent 실행됨!!!", {
|
||||||
|
componentId: component?.id,
|
||||||
|
componentType: component?.type,
|
||||||
|
webType: component?.webType,
|
||||||
|
tableName: component?.tableName,
|
||||||
|
columnName: component?.columnName,
|
||||||
|
screenId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||||
|
|
@ -257,23 +67,74 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
||||||
const [selectedLabel, setSelectedLabel] = useState("");
|
const [selectedLabel, setSelectedLabel] = useState("");
|
||||||
|
|
||||||
console.log("🔍 SelectBasicComponent 초기화:", {
|
console.log("🔍 SelectBasicComponent 초기화 (React Query):", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
externalValue,
|
externalValue,
|
||||||
componentConfigValue: componentConfig?.value,
|
componentConfigValue: componentConfig?.value,
|
||||||
webTypeConfigValue: (props as any).webTypeConfig?.value,
|
webTypeConfigValue: (props as any).webTypeConfig?.value,
|
||||||
configValue: config?.value,
|
configValue: config?.value,
|
||||||
finalSelectedValue: externalValue || config?.value || "",
|
finalSelectedValue: externalValue || config?.value || "",
|
||||||
props: Object.keys(props),
|
tableName: component.tableName,
|
||||||
|
columnName: component.columnName,
|
||||||
|
staticCodeCategory: config?.codeCategory,
|
||||||
|
// React Query 디버깅 정보
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
mountCount: ++(window as any).selectMountCount || ((window as any).selectMountCount = 1),
|
||||||
});
|
});
|
||||||
const [codeOptions, setCodeOptions] = useState<Option[]>([]);
|
|
||||||
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
// 언마운트 시 로깅
|
||||||
const [dynamicCodeCategory, setDynamicCodeCategory] = useState<string | null>(null);
|
useEffect(() => {
|
||||||
const [globalStateVersion, setGlobalStateVersion] = useState(0); // 전역 상태 변경 감지용
|
const componentId = component.id;
|
||||||
|
console.log(`🔍 [${componentId}] SelectBasicComponent 마운트됨`);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log(`🔍 [${componentId}] SelectBasicComponent 언마운트됨`);
|
||||||
|
};
|
||||||
|
}, [component.id]);
|
||||||
|
|
||||||
const selectRef = useRef<HTMLDivElement>(null);
|
const selectRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리
|
// 안정적인 쿼리 키를 위한 메모이제이션
|
||||||
const codeCategory = dynamicCodeCategory || config?.codeCategory;
|
const stableTableName = useMemo(() => component.tableName, [component.tableName]);
|
||||||
|
const stableColumnName = useMemo(() => component.columnName, [component.columnName]);
|
||||||
|
const staticCodeCategory = useMemo(() => config?.codeCategory, [config?.codeCategory]);
|
||||||
|
|
||||||
|
// 🚀 React Query: 테이블 코드 카테고리 조회
|
||||||
|
const { data: dynamicCodeCategory } = useTableCodeCategory(stableTableName, stableColumnName);
|
||||||
|
|
||||||
|
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 (메모이제이션)
|
||||||
|
const codeCategory = useMemo(() => {
|
||||||
|
const category = dynamicCodeCategory || staticCodeCategory;
|
||||||
|
console.log(`🔑 [${component.id}] 코드 카테고리 결정:`, {
|
||||||
|
dynamicCodeCategory,
|
||||||
|
staticCodeCategory,
|
||||||
|
finalCategory: category,
|
||||||
|
});
|
||||||
|
return category;
|
||||||
|
}, [dynamicCodeCategory, staticCodeCategory, component.id]);
|
||||||
|
|
||||||
|
// 🚀 React Query: 코드 옵션 조회 (안정적인 enabled 조건)
|
||||||
|
const isCodeCategoryValid = useMemo(() => {
|
||||||
|
return !!codeCategory && codeCategory !== "none";
|
||||||
|
}, [codeCategory]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
options: codeOptions,
|
||||||
|
isLoading: isLoadingCodes,
|
||||||
|
isFetching,
|
||||||
|
} = useCodeOptions(codeCategory, isCodeCategoryValid);
|
||||||
|
|
||||||
|
// React Query 상태 디버깅
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`🎯 [${component.id}] React Query 상태:`, {
|
||||||
|
codeCategory,
|
||||||
|
isCodeCategoryValid,
|
||||||
|
codeOptionsLength: codeOptions.length,
|
||||||
|
isLoadingCodes,
|
||||||
|
isFetching,
|
||||||
|
cacheStatus: isFetching ? "FETCHING" : "FROM_CACHE",
|
||||||
|
});
|
||||||
|
}, [component.id, codeCategory, isCodeCategoryValid, codeOptions.length, isLoadingCodes, isFetching]);
|
||||||
|
|
||||||
// 외부 value prop 변경 시 selectedValue 업데이트
|
// 외부 value prop 변경 시 selectedValue 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -293,109 +154,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [externalValue, config?.value]);
|
}, [externalValue, config?.value]);
|
||||||
|
|
||||||
// 🚀 전역 상태 구독 및 동기화
|
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
|
||||||
useEffect(() => {
|
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
|
||||||
const updateFromGlobalState = () => {
|
// - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
|
||||||
setGlobalStateVersion((prev) => prev + 1);
|
// - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
|
||||||
};
|
|
||||||
|
|
||||||
// 전역 상태 변경 구독
|
|
||||||
globalState.subscribers.add(updateFromGlobalState);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
globalState.subscribers.delete(updateFromGlobalState);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 🔧 테이블 코드 카테고리 로드 (전역 상태 사용)
|
|
||||||
const loadTableCodeCategory = async () => {
|
|
||||||
if (!component.tableName || !component.columnName) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`🔍 [${component.id}] 전역 테이블 코드 카테고리 조회`);
|
|
||||||
const category = await loadGlobalTableCodeCategory(component.tableName, component.columnName);
|
|
||||||
|
|
||||||
if (category !== dynamicCodeCategory) {
|
|
||||||
console.log(`🔄 [${component.id}] 코드 카테고리 변경: ${dynamicCodeCategory} → ${category}`);
|
|
||||||
setDynamicCodeCategory(category);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ [${component.id}] 테이블 코드 카테고리 조회 실패:`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🔧 코드 옵션 로드 (전역 상태 사용)
|
|
||||||
const loadCodeOptions = async (category: string) => {
|
|
||||||
if (!category || category === "none") {
|
|
||||||
setCodeOptions([]);
|
|
||||||
setIsLoadingCodes(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoadingCodes(true);
|
|
||||||
console.log(`🔄 [${component.id}] 전역 코드 옵션 로딩: ${category}`);
|
|
||||||
|
|
||||||
const options = await loadGlobalCodeOptions(category);
|
|
||||||
setCodeOptions(options);
|
|
||||||
|
|
||||||
console.log(`✅ [${component.id}] 코드 옵션 업데이트 완료: ${category} (${options.length}개)`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ [${component.id}] 코드 옵션 로딩 실패:`, error);
|
|
||||||
setCodeOptions([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingCodes(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 초기 테이블 코드 카테고리 로드
|
|
||||||
useEffect(() => {
|
|
||||||
loadTableCodeCategory();
|
|
||||||
}, [component.tableName, component.columnName]);
|
|
||||||
|
|
||||||
// 전역 상태 변경 시 동기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (component.tableName && component.columnName) {
|
|
||||||
const key = `${component.tableName}.${component.columnName}`;
|
|
||||||
const cachedCategory = globalState.tableCategories.get(key);
|
|
||||||
|
|
||||||
if (cachedCategory && cachedCategory !== dynamicCodeCategory) {
|
|
||||||
console.log(`🔄 [${component.id}] 전역 상태 동기화: ${dynamicCodeCategory} → ${cachedCategory}`);
|
|
||||||
setDynamicCodeCategory(cachedCategory || null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [globalStateVersion, component.tableName, component.columnName]);
|
|
||||||
|
|
||||||
// 코드 카테고리 변경 시 옵션 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (codeCategory && codeCategory !== "none") {
|
|
||||||
// 전역 캐시된 옵션부터 확인
|
|
||||||
const cached = globalState.codeOptions.get(codeCategory);
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
|
||||||
console.log(`🚀 [${component.id}] 전역 캐시 즉시 적용: ${codeCategory} (${cached.options.length}개)`);
|
|
||||||
setCodeOptions(cached.options);
|
|
||||||
setIsLoadingCodes(false);
|
|
||||||
} else {
|
|
||||||
loadCodeOptions(codeCategory);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setCodeOptions([]);
|
|
||||||
setIsLoadingCodes(false);
|
|
||||||
}
|
|
||||||
}, [codeCategory]);
|
|
||||||
|
|
||||||
// 전역 상태에서 코드 옵션 변경 감지
|
|
||||||
useEffect(() => {
|
|
||||||
if (codeCategory) {
|
|
||||||
const cached = globalState.codeOptions.get(codeCategory);
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
|
||||||
if (JSON.stringify(cached.options) !== JSON.stringify(codeOptions)) {
|
|
||||||
console.log(`🔄 [${component.id}] 전역 옵션 변경 감지: ${codeCategory}`);
|
|
||||||
setCodeOptions(cached.options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [globalStateVersion, codeCategory]);
|
|
||||||
|
|
||||||
// 선택된 값에 따른 라벨 업데이트
|
// 선택된 값에 따른 라벨 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -438,41 +200,20 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedValue, codeOptions, config.options]);
|
}, [selectedValue, codeOptions, config.options]);
|
||||||
|
|
||||||
// 클릭 이벤트 핸들러 (전역 상태 새로고침)
|
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (isDesignMode) return;
|
if (isDesignMode) return;
|
||||||
|
|
||||||
console.log(`🖱️ [${component.id}] 드롭다운 토글: ${isOpen} → ${!isOpen}`);
|
console.log(`🖱️ [${component.id}] 드롭다운 토글 (React Query): ${isOpen} → ${!isOpen}`);
|
||||||
console.log(`📊 [${component.id}] 현재 상태:`, {
|
console.log(`📊 [${component.id}] 현재 상태:`, {
|
||||||
isDesignMode,
|
codeCategory,
|
||||||
isLoadingCodes,
|
isLoadingCodes,
|
||||||
allOptionsLength: allOptions.length,
|
codeOptionsLength: codeOptions.length,
|
||||||
allOptions: allOptions.map((o) => ({ value: o.value, label: o.label })),
|
tableName: component.tableName,
|
||||||
|
columnName: component.columnName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 드롭다운을 열 때 전역 상태 새로고침
|
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
|
||||||
if (!isOpen) {
|
|
||||||
console.log(`🖱️ [${component.id}] 셀렉트박스 클릭 - 전역 상태 새로고침`);
|
|
||||||
|
|
||||||
// 테이블 설정 캐시 무효화 후 재로드
|
|
||||||
if (component.tableName && component.columnName) {
|
|
||||||
const key = `${component.tableName}.${component.columnName}`;
|
|
||||||
globalState.tableCategories.delete(key);
|
|
||||||
|
|
||||||
// 현재 코드 카테고리의 캐시도 무효화
|
|
||||||
if (dynamicCodeCategory) {
|
|
||||||
globalState.codeOptions.delete(dynamicCodeCategory);
|
|
||||||
console.log(`🗑️ [${component.id}] 코드 옵션 캐시 무효화: ${dynamicCodeCategory}`);
|
|
||||||
|
|
||||||
// 강제로 새로운 API 호출 수행
|
|
||||||
console.log(`🔄 [${component.id}] 강제 코드 옵션 재로드 시작: ${dynamicCodeCategory}`);
|
|
||||||
loadCodeOptions(dynamicCodeCategory);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadTableCodeCategory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -519,45 +260,19 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// 🚀 실시간 업데이트를 위한 이벤트 리스너
|
// ✅ React Query가 자동으로 처리하므로 수동 이벤트 리스너 불필요
|
||||||
useEffect(() => {
|
// - refetchOnWindowFocus: true (기본값)
|
||||||
const handleFocus = () => {
|
// - refetchOnReconnect: true (기본값)
|
||||||
console.log(`👁️ [${component.id}] 윈도우 포커스 - 전역 상태 새로고침`);
|
// - staleTime으로 적절한 캐시 관리
|
||||||
if (component.tableName && component.columnName) {
|
|
||||||
const key = `${component.tableName}.${component.columnName}`;
|
|
||||||
globalState.tableCategories.delete(key); // 캐시 무효화
|
|
||||||
loadTableCodeCategory();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
console.log(`👁️ [${component.id}] 페이지 가시성 변경 - 전역 상태 새로고침`);
|
|
||||||
if (component.tableName && component.columnName) {
|
|
||||||
const key = `${component.tableName}.${component.columnName}`;
|
|
||||||
globalState.tableCategories.delete(key); // 캐시 무효화
|
|
||||||
loadTableCodeCategory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("focus", handleFocus);
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("focus", handleFocus);
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
};
|
|
||||||
}, [component.tableName, component.columnName]);
|
|
||||||
|
|
||||||
// 모든 옵션 가져오기
|
// 모든 옵션 가져오기
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
console.log(`🔧 [${component.id}] 옵션 병합:`, {
|
console.log(`🔧 [${component.id}] 옵션 병합:`, {
|
||||||
codeOptionsLength: codeOptions.length,
|
codeOptionsLength: codeOptions.length,
|
||||||
codeOptions: codeOptions.map((o) => ({ value: o.value, label: o.label })),
|
codeOptions: codeOptions.map((o: Option) => ({ value: o.value, label: o.label })),
|
||||||
configOptionsLength: configOptions.length,
|
configOptionsLength: configOptions.length,
|
||||||
configOptions: configOptions.map((o) => ({ value: o.value, label: o.label })),
|
configOptions: configOptions.map((o: Option) => ({ value: o.value, label: o.label })),
|
||||||
});
|
});
|
||||||
return [...codeOptions, ...configOptions];
|
return [...codeOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
@ -649,7 +364,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
isDesignMode,
|
isDesignMode,
|
||||||
isLoadingCodes,
|
isLoadingCodes,
|
||||||
allOptionsLength: allOptions.length,
|
allOptionsLength: allOptions.length,
|
||||||
allOptions: allOptions.map((o) => ({ value: o.value, label: o.label })),
|
allOptions: allOptions.map((o: Option) => ({ value: o.value, label: o.label })),
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
|
|
@ -62,8 +62,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
|
<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>
|
<TableRow className="border-b border-gray-200/40">
|
||||||
{visibleColumns.map((column, colIndex) => {
|
{visibleColumns.map((column, colIndex) => {
|
||||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||||
const leftFixedWidth = visibleColumns
|
const leftFixedWidth = visibleColumns
|
||||||
|
|
@ -84,13 +84,13 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
className={cn(
|
className={cn(
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "h-10 border-b px-4 py-2 text-center align-middle"
|
? "h-12 border-0 px-4 py-3 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 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}`,
|
`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 === "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 bg-white 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",
|
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}
|
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||||
</span>
|
</span>
|
||||||
{column.sortable && (
|
{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 ? (
|
{sortColumn === column.columnName ? (
|
||||||
sortDirection === "asc" ? (
|
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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -142,8 +142,16 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<TableRow>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -151,11 +159,11 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
<TableRow
|
<TableRow
|
||||||
key={`row-${index}`}
|
key={`row-${index}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 cursor-pointer border-b leading-none",
|
"h-12 cursor-pointer border-b border-gray-100/60 leading-none transition-all duration-200",
|
||||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
|
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-gray-50/30",
|
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)}
|
onClick={() => handleRowClick(row)}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, colIndex) => {
|
{visibleColumns.map((column, colIndex) => {
|
||||||
|
|
@ -177,15 +185,15 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||||
<TableCell
|
<TableCell
|
||||||
key={`cell-${column.columnName}`}
|
key={`cell-${column.columnName}`}
|
||||||
className={cn(
|
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}`,
|
`text-${column.align}`,
|
||||||
// 고정 컬럼 스타일
|
// 고정 컬럼 스타일
|
||||||
column.fixed === "left" && "sticky z-10 border-r 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 bg-white",
|
column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-white/90 backdrop-blur-sm",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
minHeight: "40px",
|
minHeight: "48px",
|
||||||
height: "40px",
|
height: "48px",
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
width: getColumnWidth(column),
|
width: getColumnWidth(column),
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,85 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
import { TableListConfig, ColumnConfig } from "./types";
|
import { TableListConfig, ColumnConfig } from "./types";
|
||||||
import { WebType } from "@/types/common";
|
import { WebType } from "@/types/common";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { codeCache } from "@/lib/caching/codeCache";
|
import { codeCache } from "@/lib/caching/codeCache";
|
||||||
|
|
||||||
|
// 전역 테이블 캐시
|
||||||
|
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
||||||
|
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
|
||||||
|
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
|
// 캐시 정리 함수
|
||||||
|
const cleanupTableCache = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 컬럼 캐시 정리
|
||||||
|
for (const [key, entry] of tableColumnCache.entries()) {
|
||||||
|
if (now - entry.timestamp > TABLE_CACHE_TTL) {
|
||||||
|
tableColumnCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 정보 캐시 정리
|
||||||
|
for (const [key, entry] of tableInfoCache.entries()) {
|
||||||
|
if (now - entry.timestamp > TABLE_CACHE_TTL) {
|
||||||
|
tableInfoCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 주기적으로 캐시 정리 (10분마다)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setInterval(cleanupTableCache, 10 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요청 디바운싱을 위한 전역 타이머
|
||||||
|
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
// 진행 중인 요청 추적 (중복 요청 방지)
|
||||||
|
const activeRequests = new Map<string, Promise<any>>();
|
||||||
|
|
||||||
|
// 디바운싱된 API 호출 함수 (중복 요청 방지 포함)
|
||||||
|
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
|
||||||
|
return (...args: T): Promise<R> => {
|
||||||
|
// 이미 진행 중인 동일한 요청이 있으면 그 결과를 반환
|
||||||
|
const activeRequest = activeRequests.get(key);
|
||||||
|
if (activeRequest) {
|
||||||
|
console.log(`🔄 진행 중인 요청 재사용: ${key}`);
|
||||||
|
return activeRequest as Promise<R>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 기존 타이머 제거
|
||||||
|
const existingTimer = debounceTimers.get(key);
|
||||||
|
if (existingTimer) {
|
||||||
|
clearTimeout(existingTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 타이머 설정
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
// 요청 시작 시 활성 요청으로 등록
|
||||||
|
const requestPromise = fn(...args);
|
||||||
|
activeRequests.set(key, requestPromise);
|
||||||
|
|
||||||
|
const result = await requestPromise;
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
} finally {
|
||||||
|
debounceTimers.delete(key);
|
||||||
|
activeRequests.delete(key);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
debounceTimers.set(key, timer);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
@ -90,12 +164,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
} as TableListConfig;
|
} as TableListConfig;
|
||||||
|
|
||||||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||||
const buttonColor = component.style?.labelColor || '#3b83f6'; // 기본 파란색
|
const buttonColor = component.style?.labelColor || "#3b83f6"; // 기본 파란색
|
||||||
const buttonTextColor = component.config?.buttonTextColor || '#ffffff';
|
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
||||||
const buttonStyle = {
|
const buttonStyle = {
|
||||||
backgroundColor: buttonColor,
|
backgroundColor: buttonColor,
|
||||||
color: buttonTextColor,
|
color: buttonTextColor,
|
||||||
borderColor: buttonColor
|
borderColor: buttonColor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디버깅 로그 제거 (성능상 이유로)
|
// 디버깅 로그 제거 (성능상 이유로)
|
||||||
|
|
@ -119,6 +193,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
||||||
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
|
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
|
||||||
|
|
||||||
|
// 컬럼 정보 메모이제이션
|
||||||
|
const memoizedColumnInfo = useMemo(() => {
|
||||||
|
return {
|
||||||
|
labels: columnLabels,
|
||||||
|
meta: columnMeta,
|
||||||
|
visibleColumns: (tableConfig.columns || []).filter((col) => col.visible !== false),
|
||||||
|
};
|
||||||
|
}, [columnLabels, columnMeta, tableConfig.columns]);
|
||||||
|
|
||||||
// 고급 필터 관련 state
|
// 고급 필터 관련 state
|
||||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
|
@ -208,8 +291,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 디버깅 로그 제거 (성능상 이유로)
|
// 디버깅 로그 제거 (성능상 이유로)
|
||||||
|
|
||||||
|
|
||||||
// 스타일 계산 (컨테이너에 맞춤)
|
// 스타일 계산 (컨테이너에 맞춤)
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
|
|
@ -244,69 +326,117 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 자동 높이로 테이블 전체를 감쌈
|
// 자동 높이로 테이블 전체를 감쌈
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컬럼 라벨 정보 가져오기
|
// 컬럼 라벨 정보 가져오기 (캐싱 적용)
|
||||||
const fetchColumnLabels = async () => {
|
const fetchColumnLabels = async () => {
|
||||||
if (!tableConfig.selectedTable) return;
|
if (!tableConfig.selectedTable) return;
|
||||||
|
|
||||||
try {
|
// 캐시 확인
|
||||||
const response = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
const cacheKey = tableConfig.selectedTable;
|
||||||
// API 응답 구조 확인 및 컬럼 배열 추출
|
const cached = tableColumnCache.get(cacheKey);
|
||||||
const columns = Array.isArray(response) ? response : (response as any).columns || [];
|
const now = Date.now();
|
||||||
const labels: Record<string, string> = {};
|
|
||||||
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
|
|
||||||
|
|
||||||
columns.forEach((column: any) => {
|
let columns: any[] = [];
|
||||||
// 🎯 Entity 조인된 컬럼의 경우 표시 컬럼명 사용
|
|
||||||
let displayLabel = column.displayName || column.columnName;
|
|
||||||
|
|
||||||
// Entity 타입인 경우
|
if (cached && now - cached.timestamp < TABLE_CACHE_TTL) {
|
||||||
if (column.webType === "entity") {
|
console.log(`🚀 테이블 컬럼 캐시 사용: ${cacheKey}`);
|
||||||
// 우선 기준 테이블의 컬럼 라벨을 사용
|
columns = cached.columns;
|
||||||
displayLabel = column.displayName || column.columnName;
|
} else {
|
||||||
console.log(
|
try {
|
||||||
`🎯 Entity 조인 컬럼 라벨 설정: ${column.columnName} → "${displayLabel}" (기준 테이블 라벨 사용)`,
|
console.log(`🔄 테이블 컬럼 API 호출: ${cacheKey}`);
|
||||||
);
|
const response = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
||||||
}
|
// API 응답 구조 확인 및 컬럼 배열 추출
|
||||||
|
columns = Array.isArray(response) ? response : (response as any).columns || [];
|
||||||
|
|
||||||
labels[column.columnName] = displayLabel;
|
// 캐시 저장
|
||||||
// 🎯 웹타입과 코드카테고리 정보 저장
|
tableColumnCache.set(cacheKey, { columns, timestamp: now });
|
||||||
meta[column.columnName] = {
|
console.log(`✅ 테이블 컬럼 캐시 저장: ${cacheKey} (${columns.length}개 컬럼)`);
|
||||||
webType: column.webType,
|
} catch (error) {
|
||||||
codeCategory: column.codeCategory,
|
console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error);
|
||||||
};
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
setColumnLabels(labels);
|
|
||||||
setColumnMeta(meta);
|
|
||||||
console.log("🔍 컬럼 라벨 설정 완료:", labels);
|
|
||||||
console.log("🔍 컬럼 메타정보 설정 완료:", meta);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
|
||||||
|
|
||||||
|
columns.forEach((column: any) => {
|
||||||
|
// 🎯 Entity 조인된 컬럼의 경우 표시 컬럼명 사용
|
||||||
|
let displayLabel = column.displayName || column.columnName;
|
||||||
|
|
||||||
|
// Entity 타입인 경우
|
||||||
|
if (column.webType === "entity") {
|
||||||
|
// 우선 기준 테이블의 컬럼 라벨을 사용
|
||||||
|
displayLabel = column.displayName || column.columnName;
|
||||||
|
console.log(`🎯 Entity 조인 컬럼 라벨 설정: ${column.columnName} → "${displayLabel}" (기준 테이블 라벨 사용)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
labels[column.columnName] = displayLabel;
|
||||||
|
// 🎯 웹타입과 코드카테고리 정보 저장
|
||||||
|
meta[column.columnName] = {
|
||||||
|
webType: column.webType,
|
||||||
|
codeCategory: column.codeCategory,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnLabels(labels);
|
||||||
|
setColumnMeta(meta);
|
||||||
|
console.log("🔍 컬럼 라벨 설정 완료:", labels);
|
||||||
|
console.log("🔍 컬럼 메타정보 설정 완료:", meta);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🎯 전역 코드 캐시 사용으로 함수 제거 (codeCache.convertCodeToName 사용)
|
// 🎯 전역 코드 캐시 사용으로 함수 제거 (codeCache.convertCodeToName 사용)
|
||||||
|
|
||||||
// 테이블 라벨명 가져오기
|
// 테이블 라벨명 가져오기 (캐싱 적용)
|
||||||
const fetchTableLabel = async () => {
|
const fetchTableLabel = async () => {
|
||||||
if (!tableConfig.selectedTable) return;
|
if (!tableConfig.selectedTable) return;
|
||||||
|
|
||||||
try {
|
// 캐시 확인
|
||||||
const tables = await tableTypeApi.getTables();
|
const cacheKey = "all_tables";
|
||||||
const table = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
|
const cached = tableInfoCache.get(cacheKey);
|
||||||
if (table && table.displayName && table.displayName !== table.tableName) {
|
const now = Date.now();
|
||||||
setTableLabel(table.displayName);
|
|
||||||
} else {
|
let tables: any[] = [];
|
||||||
|
|
||||||
|
if (cached && now - cached.timestamp < TABLE_CACHE_TTL) {
|
||||||
|
console.log(`🚀 테이블 정보 캐시 사용: ${cacheKey}`);
|
||||||
|
tables = cached.tables;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
console.log(`🔄 테이블 정보 API 호출: ${cacheKey}`);
|
||||||
|
tables = await tableTypeApi.getTables();
|
||||||
|
|
||||||
|
// 캐시 저장
|
||||||
|
tableInfoCache.set(cacheKey, { tables, timestamp: now });
|
||||||
|
console.log(`✅ 테이블 정보 캐시 저장: ${cacheKey} (${tables.length}개 테이블)`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("테이블 라벨 정보를 가져올 수 없습니다:", error);
|
||||||
setTableLabel(tableConfig.selectedTable);
|
setTableLabel(tableConfig.selectedTable);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
console.log("테이블 라벨 정보를 가져올 수 없습니다:", error);
|
|
||||||
|
const table = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
|
||||||
|
if (table && table.displayName && table.displayName !== table.tableName) {
|
||||||
|
setTableLabel(table.displayName);
|
||||||
|
} else {
|
||||||
setTableLabel(tableConfig.selectedTable);
|
setTableLabel(tableConfig.selectedTable);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테이블 데이터 가져오기
|
// 디바운싱된 테이블 데이터 가져오기
|
||||||
const fetchTableData = async () => {
|
const fetchTableDataDebounced = useCallback(
|
||||||
|
debouncedApiCall(
|
||||||
|
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
|
||||||
|
async () => {
|
||||||
|
return fetchTableDataInternal();
|
||||||
|
},
|
||||||
|
200, // 200ms 디바운스
|
||||||
|
),
|
||||||
|
[tableConfig.selectedTable, currentPage, localPageSize, searchTerm, sortColumn, sortDirection, searchValues],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 실제 테이블 데이터 가져오기 함수
|
||||||
|
const fetchTableDataInternal = async () => {
|
||||||
if (!tableConfig.selectedTable) {
|
if (!tableConfig.selectedTable) {
|
||||||
setData([]);
|
setData([]);
|
||||||
return;
|
return;
|
||||||
|
|
@ -494,14 +624,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
codeColumns.map(([col, meta]) => `${col}(${meta.codeCategory})`),
|
codeColumns.map(([col, meta]) => `${col}(${meta.codeCategory})`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 필요한 코드 카테고리들을 추출하여 배치 로드
|
// 필요한 코드 카테고리들을 추출하여 배치 로드 (중복 제거)
|
||||||
const categoryList = codeColumns.map(([, meta]) => meta.codeCategory).filter(Boolean) as string[];
|
const categoryList = [
|
||||||
|
...new Set(codeColumns.map(([, meta]) => meta.codeCategory).filter(Boolean)),
|
||||||
|
] as string[];
|
||||||
|
|
||||||
try {
|
// 이미 캐시된 카테고리는 제외
|
||||||
await codeCache.preloadCodes(categoryList);
|
const uncachedCategories = categoryList.filter((category) => !codeCache.getCodeSync(category));
|
||||||
console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)");
|
|
||||||
} catch (error) {
|
if (uncachedCategories.length > 0) {
|
||||||
console.error("❌ 코드 캐시 로드 중 오류:", error);
|
try {
|
||||||
|
console.log(`📋 새로운 코드 카테고리 로딩: ${uncachedCategories.join(", ")}`);
|
||||||
|
await codeCache.preloadCodes(uncachedCategories);
|
||||||
|
console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 코드 캐시 로드 중 오류:", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("📋 모든 코드 카테고리가 이미 캐시됨");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -695,18 +835,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
const handleAdvancedSearch = () => {
|
const handleAdvancedSearch = () => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
fetchTableData();
|
fetchTableDataDebounced();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAdvancedFilters = () => {
|
const handleClearAdvancedFilters = () => {
|
||||||
setSearchValues({});
|
setSearchValues({});
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
fetchTableData();
|
fetchTableDataDebounced();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새로고침
|
// 새로고침
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
fetchTableData();
|
fetchTableDataDebounced();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 체크박스 핸들러들
|
// 체크박스 핸들러들
|
||||||
|
|
@ -808,7 +948,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tableConfig.autoLoad && !isDesignMode) {
|
if (tableConfig.autoLoad && !isDesignMode) {
|
||||||
fetchTableData();
|
fetchTableDataDebounced();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
|
|
@ -832,7 +972,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
console.log("🔄 선택 상태 초기화 - 빈 배열 전달");
|
console.log("🔄 선택 상태 초기화 - 빈 배열 전달");
|
||||||
onSelectedRowsChange?.([], []);
|
onSelectedRowsChange?.([], []);
|
||||||
// 테이블 데이터 새로고침
|
// 테이블 데이터 새로고침
|
||||||
fetchTableData();
|
fetchTableDataDebounced();
|
||||||
}
|
}
|
||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
|
|
||||||
|
|
@ -987,7 +1127,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" style={{ zIndex: 1 }} />;
|
return (
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
aria-label="전체 선택"
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 체크박스 셀 렌더링
|
// 체크박스 셀 렌더링
|
||||||
|
|
@ -1070,37 +1217,37 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 드래그 데이터에 그리드 정보 포함
|
// 드래그 데이터에 그리드 정보 포함
|
||||||
const dragData = {
|
const dragData = {
|
||||||
...row,
|
...row,
|
||||||
_dragType: 'table-row',
|
_dragType: "table-row",
|
||||||
_gridSize: { width: 4, height: 1 }, // 기본 그리드 크기 (4칸 너비, 1칸 높이)
|
_gridSize: { width: 4, height: 1 }, // 기본 그리드 크기 (4칸 너비, 1칸 높이)
|
||||||
_snapToGrid: true
|
_snapToGrid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||||
e.dataTransfer.effectAllowed = 'copy'; // move 대신 copy로 변경
|
e.dataTransfer.effectAllowed = "copy"; // move 대신 copy로 변경
|
||||||
|
|
||||||
// 드래그 이미지를 더 깔끔하게
|
// 드래그 이미지를 더 깔끔하게
|
||||||
const dragElement = e.currentTarget as HTMLElement;
|
const dragElement = e.currentTarget as HTMLElement;
|
||||||
|
|
||||||
// 커스텀 드래그 이미지 생성 (저장 버튼과 어울리는 스타일)
|
// 커스텀 드래그 이미지 생성 (저장 버튼과 어울리는 스타일)
|
||||||
const dragImage = document.createElement('div');
|
const dragImage = document.createElement("div");
|
||||||
dragImage.style.position = 'absolute';
|
dragImage.style.position = "absolute";
|
||||||
dragImage.style.top = '-1000px';
|
dragImage.style.top = "-1000px";
|
||||||
dragImage.style.left = '-1000px';
|
dragImage.style.left = "-1000px";
|
||||||
dragImage.style.background = 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)';
|
dragImage.style.background = "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)";
|
||||||
dragImage.style.color = 'white';
|
dragImage.style.color = "white";
|
||||||
dragImage.style.padding = '12px 16px';
|
dragImage.style.padding = "12px 16px";
|
||||||
dragImage.style.borderRadius = '8px';
|
dragImage.style.borderRadius = "8px";
|
||||||
dragImage.style.fontSize = '14px';
|
dragImage.style.fontSize = "14px";
|
||||||
dragImage.style.fontWeight = '600';
|
dragImage.style.fontWeight = "600";
|
||||||
dragImage.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)';
|
dragImage.style.boxShadow = "0 4px 12px rgba(59, 130, 246, 0.4)";
|
||||||
dragImage.style.display = 'flex';
|
dragImage.style.display = "flex";
|
||||||
dragImage.style.alignItems = 'center';
|
dragImage.style.alignItems = "center";
|
||||||
dragImage.style.gap = '8px';
|
dragImage.style.gap = "8px";
|
||||||
dragImage.style.minWidth = '200px';
|
dragImage.style.minWidth = "200px";
|
||||||
dragImage.style.whiteSpace = 'nowrap';
|
dragImage.style.whiteSpace = "nowrap";
|
||||||
|
|
||||||
// 아이콘과 텍스트 추가
|
// 아이콘과 텍스트 추가
|
||||||
const firstValue = Object.values(row)[0] || 'Row';
|
const firstValue = Object.values(row)[0] || "Row";
|
||||||
dragImage.innerHTML = `
|
dragImage.innerHTML = `
|
||||||
<div style="
|
<div style="
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|
@ -1150,12 +1297,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className} {...domProps}>
|
||||||
<div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20">
|
<div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20">
|
||||||
<div className="text-center p-8">
|
<div className="p-8 text-center">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center shadow-sm">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-100 to-indigo-100 shadow-sm">
|
||||||
<TableIcon className="h-8 w-8 text-blue-600" />
|
<TableIcon className="h-8 w-8 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold text-slate-700 mb-2">테이블 리스트</div>
|
<div className="mb-2 text-lg font-semibold text-slate-700">테이블 리스트</div>
|
||||||
<div className="text-sm text-slate-500 bg-white/60 px-4 py-2 rounded-full">
|
<div className="rounded-full bg-white/60 px-4 py-2 text-sm text-slate-500">
|
||||||
설정 패널에서 테이블을 선택해주세요
|
설정 패널에서 테이블을 선택해주세요
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1166,36 +1313,34 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{...componentStyle, zIndex: 10}} // 🎯 componentStyle + z-index 추가
|
style={{ ...componentStyle, zIndex: 10 }} // 🎯 componentStyle + z-index 추가
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg bg-white border border-gray-200 shadow-md shadow-blue-100/50",
|
"rounded-lg border border-gray-200 bg-white shadow-md shadow-blue-100/50",
|
||||||
"overflow-hidden relative", // 🎯 항상 overflow-hidden 적용 + relative 추가
|
"relative overflow-hidden", // 🎯 항상 overflow-hidden 적용 + relative 추가
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...domProps}
|
{...domProps}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
{tableConfig.showHeader && (
|
{tableConfig.showHeader && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between bg-gray-100/80 border-b border-gray-200 px-6 py-4"
|
className="flex items-center justify-between border-b border-gray-200 bg-gray-100/80 px-6 py-4"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
boxSizing: "border-box"
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{(tableConfig.title || tableLabel) && (
|
{(tableConfig.title || tableLabel) && (
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
<h3 className="text-lg font-semibold text-gray-900">{tableConfig.title || tableLabel}</h3>
|
||||||
{tableConfig.title || tableLabel}
|
|
||||||
</h3>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{/* 선택된 항목 정보 표시 */}
|
{/* 선택된 항목 정보 표시 */}
|
||||||
{selectedRows.size > 0 && (
|
{selectedRows.size > 0 && (
|
||||||
<div className="flex items-center space-x-2 bg-blue-50 px-3 py-1 rounded-md">
|
<div className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1">
|
||||||
<span className="text-sm font-medium text-blue-700">{selectedRows.size}개 선택됨</span>
|
<span className="text-sm font-medium text-blue-700">{selectedRows.size}개 선택됨</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1207,17 +1352,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={buttonStyle}
|
style={buttonStyle}
|
||||||
className="group relative shadow-sm rounded-lg [&:hover]:opacity-90"
|
className="group relative rounded-lg shadow-sm [&:hover]:opacity-90"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<RefreshCw className={cn(
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} style={{ color: buttonTextColor }} />
|
||||||
"h-4 w-4",
|
{loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-200/30"></div>}
|
||||||
loading && "animate-spin"
|
|
||||||
)} style={{ color: buttonTextColor }} />
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute -inset-1 bg-blue-200/30 rounded-full animate-pulse"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium" style={{ color: buttonTextColor }}>
|
<span className="text-sm font-medium" style={{ color: buttonTextColor }}>
|
||||||
{loading ? "새로고침 중..." : "새로고침"}
|
{loading ? "새로고침 중..." : "새로고침"}
|
||||||
|
|
@ -1239,18 +1379,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onSearchValueChange={handleSearchValueChange}
|
onSearchValueChange={handleSearchValueChange}
|
||||||
onSearch={handleAdvancedSearch}
|
onSearch={handleAdvancedSearch}
|
||||||
onClearFilters={handleClearAdvancedFilters}
|
onClearFilters={handleClearAdvancedFilters}
|
||||||
tableColumns={visibleColumns.map((col) => ({
|
tableColumns={visibleColumns.map((col) => ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
widgetType: (columnMeta[col.columnName]?.webType || "text") as WebType,
|
widgetType: (columnMeta[col.columnName]?.webType || "text") as WebType,
|
||||||
displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
|
displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||||
codeCategory: columnMeta[col.columnName]?.codeCategory,
|
codeCategory: columnMeta[col.columnName]?.codeCategory,
|
||||||
isVisible: col.visible,
|
isVisible: col.visible,
|
||||||
// 추가 메타데이터 전달 (필터 자동 생성용)
|
// 추가 메타데이터 전달 (필터 자동 생성용)
|
||||||
web_type: (columnMeta[col.columnName]?.webType || "text") as WebType,
|
web_type: (columnMeta[col.columnName]?.webType || "text") as WebType,
|
||||||
column_name: col.columnName,
|
column_name: col.columnName,
|
||||||
column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||||
code_category: columnMeta[col.columnName]?.codeCategory,
|
code_category: columnMeta[col.columnName]?.codeCategory,
|
||||||
}))}
|
}))}
|
||||||
tableName={tableConfig.selectedTable}
|
tableName={tableConfig.selectedTable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1263,50 +1403,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
boxSizing: "border-box"
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-slate-50/50 to-blue-50/30">
|
<div className="flex h-full items-center justify-center bg-gradient-to-br from-slate-50/50 to-blue-50/30">
|
||||||
<div className="text-center p-8">
|
<div className="p-8 text-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-100 to-indigo-100">
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
|
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full animate-pulse"></div>
|
<div className="absolute -top-1 -right-1 h-4 w-4 animate-pulse rounded-full bg-gradient-to-br from-blue-400 to-indigo-500"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-slate-700">데이터를 불러오는 중...</div>
|
<div className="text-sm font-medium text-slate-700">데이터를 불러오는 중...</div>
|
||||||
<div className="text-xs text-slate-500 mt-1">잠시만 기다려주세요</div>
|
<div className="mt-1 text-xs text-slate-500">잠시만 기다려주세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-red-50/50 to-orange-50/30">
|
<div className="flex h-full items-center justify-center bg-gradient-to-br from-red-50/50 to-orange-50/30">
|
||||||
<div className="text-center p-8">
|
<div className="p-8 text-center">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-100 to-orange-100 rounded-2xl flex items-center justify-center">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-red-100 to-orange-100">
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-red-500 to-orange-500">
|
||||||
<span className="text-white text-sm font-bold">!</span>
|
<span className="text-sm font-bold text-white">!</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-red-700">오류가 발생했습니다</div>
|
<div className="text-sm font-medium text-red-700">오류가 발생했습니다</div>
|
||||||
<div className="mt-1 text-xs text-red-500 bg-red-50 px-3 py-1 rounded-full">{error}</div>
|
<div className="mt-1 rounded-full bg-red-50 px-3 py-1 text-xs text-red-500">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : tableConfig.displayMode === "card" ? (
|
) : tableConfig.displayMode === "card" ? (
|
||||||
// 카드 모드 렌더링
|
// 카드 모드 렌더링
|
||||||
<div className="w-full h-full overflow-y-auto">
|
<div className="h-full w-full overflow-y-auto">
|
||||||
<CardModeRenderer
|
<CardModeRenderer
|
||||||
data={data}
|
data={data}
|
||||||
cardConfig={tableConfig.cardConfig || {
|
cardConfig={
|
||||||
idColumn: "id",
|
tableConfig.cardConfig || {
|
||||||
titleColumn: "name",
|
idColumn: "id",
|
||||||
cardsPerRow: 3,
|
titleColumn: "name",
|
||||||
cardSpacing: 16,
|
cardsPerRow: 3,
|
||||||
showActions: true,
|
cardSpacing: 16,
|
||||||
}}
|
showActions: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
onRowClick={handleRowClick}
|
onRowClick={handleRowClick}
|
||||||
onRowSelect={(row, selected) => {
|
onRowSelect={(row, selected) => {
|
||||||
const rowIndex = data.findIndex(d => d === row);
|
const rowIndex = data.findIndex((d) => d === row);
|
||||||
const rowKey = getRowKey(row, rowIndex);
|
const rowKey = getRowKey(row, rowIndex);
|
||||||
handleRowSelection(rowKey, selected);
|
handleRowSelection(rowKey, selected);
|
||||||
}}
|
}}
|
||||||
|
|
@ -1343,20 +1485,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
tableLayout: "fixed" // 테이블 크기 고정
|
tableLayout: "fixed", // 테이블 크기 고정
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader className={cn(
|
<TableHeader
|
||||||
tableConfig.stickyHeader ? "sticky top-0 z-20" : "",
|
className={cn(
|
||||||
"bg-gray-100/80 border-b border-gray-200"
|
tableConfig.stickyHeader ? "sticky top-0 z-20" : "",
|
||||||
)}>
|
"border-b border-gray-200 bg-gray-100/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<TableRow
|
<TableRow
|
||||||
style={{
|
style={{
|
||||||
minHeight: "48px !important",
|
minHeight: "48px !important",
|
||||||
height: "48px !important",
|
height: "48px !important",
|
||||||
lineHeight: "1",
|
lineHeight: "1",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%"
|
maxWidth: "100%",
|
||||||
}}
|
}}
|
||||||
className="border-none"
|
className="border-none"
|
||||||
>
|
>
|
||||||
|
|
@ -1373,13 +1517,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 align-middle px-4 py-3 text-sm font-semibold text-gray-800",
|
"h-12 px-4 py-3 align-middle text-sm font-semibold text-gray-800",
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "text-center"
|
? "text-center"
|
||||||
: "cursor-pointer whitespace-nowrap select-none",
|
: "cursor-pointer whitespace-nowrap select-none",
|
||||||
`text-${column.align}`,
|
`text-${column.align}`,
|
||||||
column.sortable && "hover:bg-orange-100 transition-colors duration-150"
|
column.sortable && "transition-colors duration-150 hover:bg-orange-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||||
>
|
>
|
||||||
|
|
@ -1387,9 +1531,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
renderCheckboxHeader()
|
renderCheckboxHeader()
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span>
|
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||||
{columnLabels[column.columnName] || column.displayName}
|
|
||||||
</span>
|
|
||||||
{column.sortable && (
|
{column.sortable && (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{sortColumn === column.columnName ? (
|
{sortColumn === column.columnName ? (
|
||||||
|
|
@ -1409,108 +1551,110 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
|
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
|
||||||
<div className="flex flex-col items-center space-y-3">
|
<div className="flex flex-col items-center space-y-3">
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-slate-100 to-slate-200 rounded-2xl flex items-center justify-center">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-slate-100 to-slate-200">
|
||||||
<TableIcon className="h-6 w-6 text-slate-400" />
|
<TableIcon className="h-6 w-6 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-slate-600">데이터가 없습니다</div>
|
||||||
|
<div className="text-xs text-slate-400">조건을 변경하거나 새로운 데이터를 추가해보세요</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-slate-600">데이터가 없습니다</div>
|
</TableCell>
|
||||||
<div className="text-xs text-slate-400">조건을 변경하거나 새로운 데이터를 추가해보세요</div>
|
</TableRow>
|
||||||
</div>
|
) : (
|
||||||
</TableCell>
|
data.map((row, index) => (
|
||||||
</TableRow>
|
<TableRow
|
||||||
) : (
|
key={index}
|
||||||
data.map((row, index) => (
|
draggable={!isDesignMode}
|
||||||
<TableRow
|
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
||||||
key={index}
|
onDragEnd={handleRowDragEnd}
|
||||||
draggable={!isDesignMode}
|
className={cn(
|
||||||
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
"group relative h-12 cursor-pointer border-b border-gray-100 transition-all duration-200",
|
||||||
onDragEnd={handleRowDragEnd}
|
// 기본 스타일
|
||||||
className={cn(
|
tableConfig.tableStyle?.hoverEffect &&
|
||||||
"group relative h-12 cursor-pointer transition-all duration-200 border-b border-gray-100",
|
"hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm",
|
||||||
// 기본 스타일
|
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80",
|
||||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm",
|
// 드래그 상태 스타일 (미묘하게)
|
||||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80",
|
draggedRowIndex === index &&
|
||||||
// 드래그 상태 스타일 (미묘하게)
|
"border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm",
|
||||||
draggedRowIndex === index && "bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm border-blue-200",
|
isDragging && draggedRowIndex !== index && "opacity-70",
|
||||||
isDragging && draggedRowIndex !== index && "opacity-70",
|
// 드래그 가능 표시
|
||||||
// 드래그 가능 표시
|
!isDesignMode && "hover:cursor-grab active:cursor-grabbing",
|
||||||
!isDesignMode && "hover:cursor-grab active:cursor-grabbing"
|
)}
|
||||||
)}
|
style={{
|
||||||
style={{
|
minHeight: "48px",
|
||||||
minHeight: "48px",
|
height: "48px",
|
||||||
height: "48px",
|
lineHeight: "1",
|
||||||
lineHeight: "1",
|
width: "100%",
|
||||||
width: "100%",
|
maxWidth: "100%",
|
||||||
maxWidth: "100%"
|
}}
|
||||||
}}
|
onClick={() => handleRowClick(row)}
|
||||||
onClick={() => handleRowClick(row)}
|
>
|
||||||
>
|
{visibleColumns.map((column, colIndex) => (
|
||||||
{visibleColumns.map((column, colIndex) => (
|
<TableCell
|
||||||
<TableCell
|
key={column.columnName}
|
||||||
key={column.columnName}
|
className={cn(
|
||||||
className={cn(
|
"h-12 px-4 py-3 align-middle text-sm transition-all duration-200",
|
||||||
"h-12 align-middle px-4 py-3 text-sm transition-all duration-200",
|
`text-${column.align}`,
|
||||||
`text-${column.align}`
|
)}
|
||||||
)}
|
style={{
|
||||||
style={{
|
minHeight: "48px",
|
||||||
minHeight: "48px",
|
height: "48px",
|
||||||
height: "48px",
|
verticalAlign: "middle",
|
||||||
verticalAlign: "middle",
|
width: column.width ? `${column.width}px` : undefined,
|
||||||
width: column.width ? `${column.width}px` : undefined,
|
boxSizing: "border-box",
|
||||||
boxSizing: "border-box",
|
overflow: "hidden",
|
||||||
overflow: "hidden",
|
textOverflow: "ellipsis",
|
||||||
textOverflow: "ellipsis",
|
whiteSpace: "nowrap",
|
||||||
whiteSpace: "nowrap"
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{column.columnName === "__checkbox__"
|
||||||
{column.columnName === "__checkbox__"
|
? renderCheckboxCell(row, index)
|
||||||
? renderCheckboxCell(row, index)
|
: (() => {
|
||||||
: (() => {
|
// 🎯 매핑된 컬럼명으로 데이터 찾기
|
||||||
// 🎯 매핑된 컬럼명으로 데이터 찾기
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||||||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
const cellValue = row[mappedColumnName];
|
||||||
const cellValue = row[mappedColumnName];
|
if (index === 0) {
|
||||||
if (index === 0) {
|
// 디버깅 로그 제거 (성능상 이유로)
|
||||||
// 디버깅 로그 제거 (성능상 이유로)
|
}
|
||||||
}
|
const formattedValue =
|
||||||
const formattedValue = formatCellValue(cellValue, column.format, column.columnName) || "\u00A0";
|
formatCellValue(cellValue, column.format, column.columnName) || "\u00A0";
|
||||||
|
|
||||||
// 첫 번째 컬럼에 드래그 핸들과 아바타 추가
|
// 첫 번째 컬럼에 드래그 핸들과 아바타 추가
|
||||||
const isFirstColumn = colIndex === (visibleColumns[0]?.columnName === "__checkbox__" ? 1 : 0);
|
const isFirstColumn =
|
||||||
|
colIndex === (visibleColumns[0]?.columnName === "__checkbox__" ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{isFirstColumn && !isDesignMode && (
|
{isFirstColumn && !isDesignMode && (
|
||||||
<div className="opacity-60 cursor-grab active:cursor-grabbing mr-1">
|
<div className="mr-1 cursor-grab opacity-60 active:cursor-grabbing">
|
||||||
{/* 그리드 스냅 가이드 아이콘 */}
|
{/* 그리드 스냅 가이드 아이콘 */}
|
||||||
<div className="flex space-x-0.5">
|
<div className="flex space-x-0.5">
|
||||||
<div className="flex flex-col space-y-0.5">
|
<div className="flex flex-col space-y-0.5">
|
||||||
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
|
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
|
||||||
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
|
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col space-y-0.5">
|
<div className="flex flex-col space-y-0.5">
|
||||||
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
|
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
|
||||||
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
|
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<span className="font-medium text-gray-700">{formattedValue}</span>
|
||||||
<span className="font-medium text-gray-700">
|
</div>
|
||||||
{formattedValue}
|
);
|
||||||
</span>
|
})()}
|
||||||
</div>
|
</TableCell>
|
||||||
);
|
))}
|
||||||
})()}
|
</TableRow>
|
||||||
</TableCell>
|
))
|
||||||
))}
|
)}
|
||||||
</TableRow>
|
</TableBody>
|
||||||
))
|
</Table>
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1518,20 +1662,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
{/* 푸터/페이지네이션 */}
|
{/* 푸터/페이지네이션 */}
|
||||||
{tableConfig.showFooter && tableConfig.pagination?.enabled && (
|
{tableConfig.showFooter && tableConfig.pagination?.enabled && (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col items-center justify-center bg-gray-100/80 border-t border-gray-200 p-6 space-y-4"
|
className="flex flex-col items-center justify-center space-y-4 border-t border-gray-200 bg-gray-100/80 p-6"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
boxSizing: "border-box"
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 페이지 정보 - 가운데 정렬 */}
|
{/* 페이지 정보 - 가운데 정렬 */}
|
||||||
{tableConfig.pagination?.showPageInfo && (
|
{tableConfig.pagination?.showPageInfo && (
|
||||||
<div className="flex items-center justify-center space-x-2 text-sm text-slate-600">
|
<div className="flex items-center justify-center space-x-2 text-sm text-slate-600">
|
||||||
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
<div className="h-2 w-2 rounded-full bg-blue-500"></div>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
전체 <span className="text-blue-600 font-semibold">{totalItems.toLocaleString()}</span>건 중{" "}
|
전체 <span className="font-semibold text-blue-600">{totalItems.toLocaleString()}</span>건 중{" "}
|
||||||
<span className="text-slate-800 font-semibold">
|
<span className="font-semibold text-slate-800">
|
||||||
{(currentPage - 1) * localPageSize + 1}-{Math.min(currentPage * localPageSize, totalItems)}
|
{(currentPage - 1) * localPageSize + 1}-{Math.min(currentPage * localPageSize, totalItems)}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
표시
|
표시
|
||||||
|
|
@ -1578,7 +1722,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 데이터는 useEffect에서 자동으로 다시 로드됨
|
// 데이터는 useEffect에서 자동으로 다시 로드됨
|
||||||
}}
|
}}
|
||||||
className="bg-white/80 border border-slate-200 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-white hover:border-slate-300 transition-colors"
|
className="rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:bg-white"
|
||||||
>
|
>
|
||||||
{(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
|
{(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>
|
||||||
|
|
@ -1589,13 +1733,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지네이션 버튼 */}
|
{/* 페이지네이션 버튼 */}
|
||||||
<div className="flex items-center space-x-2 bg-white rounded-lg border border-gray-200 shadow-sm p-1">
|
<div className="flex items-center space-x-2 rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(1)}
|
onClick={() => handlePageChange(1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
|
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<ChevronsLeft className="h-4 w-4" />
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1604,19 +1748,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage - 1)}
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
|
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center px-4 py-1 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-md border border-blue-100">
|
<div className="flex items-center rounded-md border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 px-4 py-1">
|
||||||
<span className="text-sm font-semibold text-blue-800">
|
<span className="text-sm font-semibold text-blue-800">{currentPage}</span>
|
||||||
{currentPage}
|
<span className="mx-2 font-light text-gray-400">/</span>
|
||||||
</span>
|
<span className="text-sm font-medium text-gray-600">{totalPages}</span>
|
||||||
<span className="text-gray-400 mx-2 font-light">/</span>
|
|
||||||
<span className="text-sm font-medium text-gray-600">
|
|
||||||
{totalPages}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1624,7 +1764,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage + 1)}
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
|
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1633,7 +1773,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(totalPages)}
|
onClick={() => handlePageChange(totalPages)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
|
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<ChevronsRight className="h-4 w-4" />
|
<ChevronsRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,10 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
|
||||||
const zoneStyle: React.CSSProperties = {
|
const zoneStyle: React.CSSProperties = {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
// 구역 경계 시각화 - 항상 표시
|
// 구역 경계 시각화 - 항상 표시
|
||||||
border: "1px solid #e2e8f0",
|
border: "1px solid rgba(226, 232, 240, 0.6)",
|
||||||
borderRadius: "6px",
|
borderRadius: "12px",
|
||||||
backgroundColor: "rgba(248, 250, 252, 0.5)",
|
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",
|
transition: "all 0.2s ease",
|
||||||
...this.getZoneStyle(zone),
|
...this.getZoneStyle(zone),
|
||||||
...additionalProps.style,
|
...additionalProps.style,
|
||||||
|
|
@ -62,19 +63,21 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
// 🎯 컴포넌트가 있는 존은 테두리 제거 (컴포넌트 자체 테두리와 충돌 방지)
|
// 🎯 컴포넌트가 있는 존은 테두리 제거 (컴포넌트 자체 테두리와 충돌 방지)
|
||||||
if (zoneChildren.length === 0) {
|
if (zoneChildren.length === 0) {
|
||||||
zoneStyle.border = "2px dashed #cbd5e1";
|
zoneStyle.border = "2px dashed rgba(203, 213, 225, 0.6)";
|
||||||
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
|
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.5)";
|
||||||
|
zoneStyle.borderRadius = "12px";
|
||||||
} else {
|
} else {
|
||||||
// 컴포넌트가 있는 존은 미묘한 배경만
|
// 컴포넌트가 있는 존은 미묘한 배경만
|
||||||
zoneStyle.border = "1px solid transparent";
|
zoneStyle.border = "1px solid rgba(226, 232, 240, 0.3)";
|
||||||
zoneStyle.backgroundColor = "rgba(248, 250, 252, 0.3)";
|
zoneStyle.backgroundColor = "rgba(248, 250, 252, 0.2)";
|
||||||
|
zoneStyle.borderRadius = "12px";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 호버 효과를 위한 추가 스타일
|
// 호버 효과를 위한 추가 스타일
|
||||||
const dropZoneStyle: React.CSSProperties = {
|
const dropZoneStyle: React.CSSProperties = {
|
||||||
minHeight: isDesignMode ? "60px" : "40px",
|
minHeight: isDesignMode ? "80px" : "50px",
|
||||||
borderRadius: "4px",
|
borderRadius: "12px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -39,7 +39,9 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"docx-preview": "^0.3.6",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"next": "15.4.4",
|
"next": "15.4.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.9.0",
|
||||||
|
|
@ -47,8 +49,10 @@
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-window": "^2.1.0",
|
"react-window": "^2.1.0",
|
||||||
|
"sheetjs-style": "^0.15.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -3351,6 +3355,15 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||||
|
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xyflow/react": {
|
"node_modules/@xyflow/react": {
|
||||||
"version": "12.8.5",
|
"version": "12.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.5.tgz",
|
||||||
|
|
@ -3406,6 +3419,22 @@
|
||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"exit-on-epipe": "~1.0.1",
|
||||||
|
"printj": "~1.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"adler32": "bin/adler32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
|
|
@ -3705,6 +3734,32 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||||
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
|
@ -3837,6 +3892,28 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cfb/node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -3939,6 +4016,28 @@
|
||||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "~2.14.1",
|
||||||
|
"exit-on-epipe": "~1.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"codepage": "bin/codepage.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/codepage/node_modules/commander": {
|
||||||
|
"version": "2.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz",
|
||||||
|
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -3971,6 +4070,12 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
|
||||||
|
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
|
@ -3995,6 +4100,24 @@
|
||||||
"node": "^14.18.0 || >=16.10.0"
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4308,6 +4431,12 @@
|
||||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dingbat-to-unicode": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
|
|
@ -4321,6 +4450,15 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/docx-preview": {
|
||||||
|
"version": "0.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz",
|
||||||
|
"integrity": "sha512-gKVPE18hlpfuhQHiptsw1rbOwzQeGSwK10/w7hv1ZMEqHmjtCuTpz6AUMfu1twIPGxgpcsMXThKI6B6WsP3L1w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jszip": ">=3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
|
@ -4334,6 +4472,15 @@
|
||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duck": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||||
|
"license": "BSD",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -5049,6 +5196,15 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/exit-on-epipe": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
|
||||||
|
|
@ -5263,6 +5419,15 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|
@ -5577,6 +5742,12 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -5604,6 +5775,12 @@
|
||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
|
|
@ -6132,6 +6309,18 @@
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
@ -6176,6 +6365,15 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||||
|
|
@ -6451,6 +6649,17 @@
|
||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lop": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"duck": "^0.1.12",
|
||||||
|
"option": "~0.2.1",
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "0.525.0",
|
"version": "0.525.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
|
||||||
|
|
@ -6470,6 +6679,39 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mammoth": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.6",
|
||||||
|
"argparse": "~1.0.3",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
|
"bluebird": "~3.4.0",
|
||||||
|
"dingbat-to-unicode": "^1.0.1",
|
||||||
|
"jszip": "^3.7.1",
|
||||||
|
"lop": "^0.4.2",
|
||||||
|
"path-is-absolute": "^1.0.0",
|
||||||
|
"underscore": "^1.13.1",
|
||||||
|
"xmlbuilder": "^10.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mammoth": "bin/mammoth"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mammoth/node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
@ -6855,6 +7097,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/option": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -6923,6 +7171,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -6946,6 +7200,15 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -7173,6 +7436,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/printj": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"printj": "bin/printj.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.16.2",
|
"version": "6.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
|
||||||
|
|
@ -7199,6 +7474,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|
@ -7437,6 +7718,27 @@
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
|
@ -7591,6 +7893,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
|
|
@ -7694,6 +8002,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.4",
|
"version": "0.34.4",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
|
||||||
|
|
@ -7760,6 +8074,28 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sheetjs-style": {
|
||||||
|
"version": "0.15.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/sheetjs-style/-/sheetjs-style-0.15.8.tgz",
|
||||||
|
"integrity": "sha512-/wRiwnq5ck7aO+zLBs+u5JqQK4agUTIGCS0nxgaMjFl6XdlVaaB/RNJcP6S6Efj3+RYbSZuAoyqmSnbzxfT7Kg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.2.0",
|
||||||
|
"cfb": "^1.1.4",
|
||||||
|
"codepage": "~1.14.0",
|
||||||
|
"commander": "~2.17.1",
|
||||||
|
"crc-32": "~1.2.0",
|
||||||
|
"exit-on-epipe": "~1.0.1",
|
||||||
|
"ssf": "~0.10.3",
|
||||||
|
"wmf": "~1.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
|
@ -7855,6 +8191,27 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.10.3.tgz",
|
||||||
|
"integrity": "sha512-pRuUdW0WwyB2doSqqjWyzwCD6PkfxpHAHdZp39K3dp/Hq7f+xfMwNAWIi16DyrRg4gg9c/RvLYkJTSawTPTm1w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ssf": "bin/ssf.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
|
|
@ -7876,6 +8233,15 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string.prototype.includes": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
|
|
@ -8359,6 +8725,12 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|
@ -8463,6 +8835,12 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
@ -8568,6 +8946,24 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|
@ -8578,6 +8974,66 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx/node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx/node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx/node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"docx-preview": "^0.3.6",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"next": "15.4.4",
|
"next": "15.4.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.9.0",
|
||||||
|
|
@ -55,8 +57,10 @@
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-window": "^2.1.0",
|
"react-window": "^2.1.0",
|
||||||
|
"sheetjs-style": "^0.15.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
|
|
@ -17,13 +17,22 @@ export interface CodeCategory {
|
||||||
export type CategoryInfo = CodeCategory;
|
export type CategoryInfo = CodeCategory;
|
||||||
|
|
||||||
export interface CodeInfo {
|
export interface CodeInfo {
|
||||||
code_category: string;
|
// 백엔드 응답 필드 (변환된 형태)
|
||||||
code_value: string;
|
codeValue?: string;
|
||||||
code_name: string;
|
codeName?: string;
|
||||||
code_name_eng?: string | null;
|
codeNameEng?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
sort_order: number;
|
sortOrder?: number;
|
||||||
is_active: string;
|
isActive?: string | boolean;
|
||||||
|
useYn?: string;
|
||||||
|
|
||||||
|
// 기존 필드 (하위 호환성을 위해 유지)
|
||||||
|
code_category?: string;
|
||||||
|
code_value?: string;
|
||||||
|
code_name?: string;
|
||||||
|
code_name_eng?: string | null;
|
||||||
|
sort_order?: number;
|
||||||
|
is_active?: string;
|
||||||
created_date?: string | null;
|
created_date?: string | null;
|
||||||
created_by?: string | null;
|
created_by?: string | null;
|
||||||
updated_date?: string | null;
|
updated_date?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export interface ProfileFormData {
|
||||||
export interface ProfileModalState {
|
export interface ProfileModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
formData: ProfileFormData;
|
formData: ProfileFormData;
|
||||||
selectedImage: string;
|
selectedImage: string | null;
|
||||||
selectedFile: File | null;
|
selectedFile: File | null;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,9 @@ export interface FileComponent extends BaseComponent {
|
||||||
type: "file";
|
type: "file";
|
||||||
fileConfig: FileTypeConfig;
|
fileConfig: FileTypeConfig;
|
||||||
uploadedFiles?: UploadedFile[];
|
uploadedFiles?: UploadedFile[];
|
||||||
|
columnName?: string;
|
||||||
|
tableName?: string;
|
||||||
|
lastFileUpdate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -193,12 +196,21 @@ export interface TextTypeConfig {
|
||||||
* 파일 타입 설정
|
* 파일 타입 설정
|
||||||
*/
|
*/
|
||||||
export interface FileTypeConfig {
|
export interface FileTypeConfig {
|
||||||
accept?: string;
|
accept?: string[];
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
maxSize?: number; // bytes
|
maxSize?: number; // MB
|
||||||
maxFiles?: number;
|
maxFiles?: number;
|
||||||
preview?: boolean;
|
showPreview?: boolean;
|
||||||
|
showProgress?: boolean;
|
||||||
docType?: string;
|
docType?: string;
|
||||||
|
docTypeName?: string;
|
||||||
|
dragDropText?: string;
|
||||||
|
uploadButtonText?: string;
|
||||||
|
autoUpload?: boolean;
|
||||||
|
chunkedUpload?: boolean;
|
||||||
|
linkedTable?: string;
|
||||||
|
linkedField?: string;
|
||||||
|
autoLink?: boolean;
|
||||||
companyCode?: CompanyCode;
|
companyCode?: CompanyCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,6 +284,8 @@ export interface UploadedFile {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
docType?: string;
|
docType?: string;
|
||||||
docTypeName?: string;
|
docTypeName?: string;
|
||||||
|
targetObjid: string;
|
||||||
|
parentTargetObjid?: string;
|
||||||
writer?: string;
|
writer?: string;
|
||||||
regdate?: string;
|
regdate?: string;
|
||||||
status?: "uploading" | "completed" | "error";
|
status?: "uploading" | "completed" | "error";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
테스트 파일입니다.
|
||||||
Loading…
Reference in New Issue