화면관리 삭제기능구현
This commit is contained in:
parent
87ce1b74d4
commit
1eeda775ef
|
|
@ -1494,6 +1494,7 @@ model menu_info {
|
||||||
lang_key String? @db.VarChar(100)
|
lang_key String? @db.VarChar(100)
|
||||||
lang_key_desc String? @db.VarChar(100)
|
lang_key_desc String? @db.VarChar(100)
|
||||||
company company_mng? @relation(fields: [company_code], references: [company_code])
|
company company_mng? @relation(fields: [company_code], references: [company_code])
|
||||||
|
screen_assignments screen_menu_assignments[]
|
||||||
|
|
||||||
@@index([parent_obj_id])
|
@@index([parent_obj_id])
|
||||||
@@index([company_code])
|
@@index([company_code])
|
||||||
|
|
@ -4989,20 +4990,25 @@ model zz_230410_user_info {
|
||||||
model screen_definitions {
|
model screen_definitions {
|
||||||
screen_id Int @id @default(autoincrement())
|
screen_id Int @id @default(autoincrement())
|
||||||
screen_name String @db.VarChar(100)
|
screen_name String @db.VarChar(100)
|
||||||
screen_code String @unique @db.VarChar(50)
|
screen_code String @db.VarChar(50)
|
||||||
table_name String @db.VarChar(100)
|
table_name String @db.VarChar(100)
|
||||||
company_code String @db.VarChar(50)
|
company_code String @db.VarChar(50)
|
||||||
description String?
|
description String?
|
||||||
is_active String @default("Y") @db.Char(1)
|
is_active String @default("Y") @db.Char(1) // Y=활성, N=비활성, D=삭제됨(휴지통)
|
||||||
layout_metadata Json?
|
layout_metadata Json?
|
||||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
updated_date DateTime @default(now()) @db.Timestamp(6)
|
updated_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
updated_by String? @db.VarChar(50)
|
updated_by String? @db.VarChar(50)
|
||||||
|
deleted_date DateTime? @db.Timestamp(6) // 삭제 일시 (휴지통 이동 시점)
|
||||||
|
deleted_by String? @db.VarChar(50) // 삭제한 사용자
|
||||||
|
delete_reason String? // 삭제 사유 (선택사항)
|
||||||
layouts screen_layouts[]
|
layouts screen_layouts[]
|
||||||
menu_assignments screen_menu_assignments[]
|
menu_assignments screen_menu_assignments[]
|
||||||
|
|
||||||
@@index([company_code])
|
@@index([company_code])
|
||||||
|
@@index([is_active, company_code])
|
||||||
|
@@index([deleted_date], map: "idx_screen_definitions_deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
model screen_layouts {
|
model screen_layouts {
|
||||||
|
|
@ -5066,6 +5072,7 @@ model screen_menu_assignments {
|
||||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
created_by String? @db.VarChar(50)
|
created_by String? @db.VarChar(50)
|
||||||
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
|
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
|
||||||
|
menu_info menu_info @relation(fields: [menu_objid], references: [objid])
|
||||||
|
|
||||||
@@unique([screen_id, menu_objid, company_code])
|
@@unique([screen_id, menu_objid, company_code])
|
||||||
@@index([company_code])
|
@@index([company_code])
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import cors from "cors";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import compression from "compression";
|
import compression from "compression";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
|
import path from "path";
|
||||||
import config from "./config/environment";
|
import config from "./config/environment";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { errorHandler } from "./middleware/errorHandler";
|
import { errorHandler } from "./middleware/errorHandler";
|
||||||
|
|
@ -29,6 +30,23 @@ 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" }));
|
||||||
|
|
||||||
|
// 정적 파일 서빙 (업로드된 파일들)
|
||||||
|
app.use(
|
||||||
|
"/uploads",
|
||||||
|
express.static(path.join(process.cwd(), "uploads"), {
|
||||||
|
setHeaders: (res, path) => {
|
||||||
|
// 파일 서빙 시 CORS 헤더 설정
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
res.setHeader(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Content-Type, Authorization"
|
||||||
|
);
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,125 @@ export const getFileList = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 미리보기 (이미지 등)
|
||||||
|
*/
|
||||||
|
export const previewFile = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { objid } = req.params;
|
||||||
|
const { serverFilename } = req.query;
|
||||||
|
|
||||||
|
console.log("👁️ 파일 미리보기 요청:", { objid, serverFilename });
|
||||||
|
|
||||||
|
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||||
|
where: {
|
||||||
|
objid: parseInt(objid),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "파일을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 경로에서 회사코드와 날짜 폴더 추출
|
||||||
|
const filePathParts = fileRecord.file_path!.split("/");
|
||||||
|
const companyCode = filePathParts[2] || "DEFAULT";
|
||||||
|
const fileName = fileRecord.saved_file_name!;
|
||||||
|
|
||||||
|
// 파일 경로에 날짜 구조가 있는지 확인 (YYYY/MM/DD)
|
||||||
|
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);
|
||||||
|
|
||||||
|
console.log("👁️ 파일 미리보기 경로 확인:", {
|
||||||
|
stored_file_path: fileRecord.file_path,
|
||||||
|
company_code: companyCode,
|
||||||
|
company_upload_dir: companyUploadDir,
|
||||||
|
final_file_path: filePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error("❌ 파일 없음:", filePath);
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: `실제 파일을 찾을 수 없습니다: ${filePath}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME 타입 설정
|
||||||
|
const ext = path.extname(fileName).toLowerCase();
|
||||||
|
let mimeType = "application/octet-stream";
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
mimeType = "image/jpeg";
|
||||||
|
break;
|
||||||
|
case ".png":
|
||||||
|
mimeType = "image/png";
|
||||||
|
break;
|
||||||
|
case ".gif":
|
||||||
|
mimeType = "image/gif";
|
||||||
|
break;
|
||||||
|
case ".webp":
|
||||||
|
mimeType = "image/webp";
|
||||||
|
break;
|
||||||
|
case ".pdf":
|
||||||
|
mimeType = "application/pdf";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mimeType = "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS 헤더 설정 (더 포괄적으로)
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader(
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
);
|
||||||
|
res.setHeader(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Content-Type, Authorization, X-Requested-With, Accept, Origin"
|
||||||
|
);
|
||||||
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||||
|
|
||||||
|
// 캐시 헤더 설정
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||||
|
res.setHeader("Content-Type", mimeType);
|
||||||
|
|
||||||
|
// 파일 스트림으로 전송
|
||||||
|
const fileStream = fs.createReadStream(filePath);
|
||||||
|
fileStream.pipe(res);
|
||||||
|
|
||||||
|
console.log("✅ 파일 미리보기 완료:", {
|
||||||
|
objid,
|
||||||
|
fileName: fileRecord.real_file_name,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("파일 미리보기 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "파일 미리보기 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 파일 다운로드
|
* 파일 다운로드
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -90,24 +90,180 @@ export const updateScreen = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 삭제
|
// 화면 의존성 체크
|
||||||
export const deleteScreen = async (
|
export const checkScreenDependencies = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response
|
res: Response
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode } = req.user as any;
|
const { companyCode } = req.user as any;
|
||||||
await screenManagementService.deleteScreen(parseInt(id), companyCode);
|
|
||||||
res.json({ success: true, message: "화면이 삭제되었습니다." });
|
const result = await screenManagementService.checkScreenDependencies(
|
||||||
|
parseInt(id),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
res.json({ success: true, ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("화면 의존성 체크 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "의존성 체크에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 삭제 (휴지통으로 이동)
|
||||||
|
export const deleteScreen = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
const { deleteReason, force } = req.body;
|
||||||
|
|
||||||
|
await screenManagementService.deleteScreen(
|
||||||
|
parseInt(id),
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
deleteReason,
|
||||||
|
force || false
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: "화면이 휴지통으로 이동되었습니다." });
|
||||||
|
} catch (error: any) {
|
||||||
console.error("화면 삭제 실패:", error);
|
console.error("화면 삭제 실패:", error);
|
||||||
|
|
||||||
|
// 의존성 오류인 경우 특별 처리
|
||||||
|
if (error.code === "SCREEN_HAS_DEPENDENCIES") {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message,
|
||||||
|
code: error.code,
|
||||||
|
dependencies: error.dependencies,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res
|
res
|
||||||
.status(500)
|
.status(500)
|
||||||
.json({ success: false, message: "화면 삭제에 실패했습니다." });
|
.json({ success: false, message: "화면 삭제에 실패했습니다." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 화면 복원 (휴지통에서 복원)
|
||||||
|
export const restoreScreen = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { companyCode, userId } = req.user as any;
|
||||||
|
|
||||||
|
await screenManagementService.restoreScreen(
|
||||||
|
parseInt(id),
|
||||||
|
companyCode,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: "화면이 복원되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 복원 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "화면 복원에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 영구 삭제
|
||||||
|
export const permanentDeleteScreen = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
|
||||||
|
await screenManagementService.permanentDeleteScreen(
|
||||||
|
parseInt(id),
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: "화면이 영구적으로 삭제되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 영구 삭제 실패:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ success: false, message: "화면 영구 삭제에 실패했습니다." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 휴지통 화면 목록 조회
|
||||||
|
export const getDeletedScreens = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const size = parseInt(req.query.size as string) || 20;
|
||||||
|
|
||||||
|
const result = await screenManagementService.getDeletedScreens(
|
||||||
|
companyCode,
|
||||||
|
page,
|
||||||
|
size
|
||||||
|
);
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("휴지통 화면 목록 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "휴지통 화면 목록 조회에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 휴지통 화면 일괄 영구 삭제
|
||||||
|
export const bulkPermanentDeleteScreens = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { companyCode } = req.user as any;
|
||||||
|
const { screenIds } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(screenIds) || screenIds.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "삭제할 화면 ID 목록이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await screenManagementService.bulkPermanentDeleteScreens(
|
||||||
|
screenIds,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = `${result.deletedCount}개 화면이 영구 삭제되었습니다.`;
|
||||||
|
if (result.skippedCount > 0) {
|
||||||
|
message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
result: {
|
||||||
|
deletedCount: result.deletedCount,
|
||||||
|
skippedCount: result.skippedCount,
|
||||||
|
errors: result.errors,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("휴지통 화면 일괄 삭제 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "일괄 삭제에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 화면 복사
|
// 화면 복사
|
||||||
export const copyScreen = async (
|
export const copyScreen = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -349,3 +505,26 @@ export const unassignScreenFromMenu = async (
|
||||||
.json({ success: false, message: "화면-메뉴 할당 해제에 실패했습니다." });
|
.json({ success: false, message: "화면-메뉴 할당 해제에 실패했습니다." });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 휴지통 화면들의 메뉴 할당 정리 (관리자용)
|
||||||
|
export const cleanupDeletedScreenMenuAssignments = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
await screenManagementService.cleanupDeletedScreenMenuAssignments();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: result.message,
|
||||||
|
updatedCount: result.updatedCount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("메뉴 할당 정리 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "메뉴 할당 정리에 실패했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
deleteFile,
|
deleteFile,
|
||||||
getFileList,
|
getFileList,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
previewFile,
|
||||||
getLinkedFiles,
|
getLinkedFiles,
|
||||||
uploadMiddleware,
|
uploadMiddleware,
|
||||||
} from "../controllers/fileController";
|
} from "../controllers/fileController";
|
||||||
|
|
@ -43,6 +44,13 @@ router.get("/linked/:tableName/:recordId", getLinkedFiles);
|
||||||
*/
|
*/
|
||||||
router.delete("/:objid", deleteFile);
|
router.delete("/:objid", deleteFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/files/preview/:objid
|
||||||
|
* @desc 파일 미리보기 (이미지 등)
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get("/preview/:objid", previewFile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/files/download/:objid
|
* @route GET /api/files/download/:objid
|
||||||
* @desc 파일 다운로드
|
* @desc 파일 다운로드
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import {
|
||||||
createScreen,
|
createScreen,
|
||||||
updateScreen,
|
updateScreen,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
|
checkScreenDependencies,
|
||||||
|
restoreScreen,
|
||||||
|
permanentDeleteScreen,
|
||||||
|
getDeletedScreens,
|
||||||
|
bulkPermanentDeleteScreens,
|
||||||
copyScreen,
|
copyScreen,
|
||||||
getTables,
|
getTables,
|
||||||
getTableInfo,
|
getTableInfo,
|
||||||
|
|
@ -16,6 +21,7 @@ import {
|
||||||
assignScreenToMenu,
|
assignScreenToMenu,
|
||||||
getScreensByMenu,
|
getScreensByMenu,
|
||||||
unassignScreenFromMenu,
|
unassignScreenFromMenu,
|
||||||
|
cleanupDeletedScreenMenuAssignments,
|
||||||
} from "../controllers/screenManagementController";
|
} from "../controllers/screenManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -28,9 +34,16 @@ router.get("/screens", getScreens);
|
||||||
router.get("/screens/:id", getScreen);
|
router.get("/screens/:id", getScreen);
|
||||||
router.post("/screens", createScreen);
|
router.post("/screens", createScreen);
|
||||||
router.put("/screens/:id", updateScreen);
|
router.put("/screens/:id", updateScreen);
|
||||||
router.delete("/screens/:id", deleteScreen);
|
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
|
||||||
|
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
|
||||||
router.post("/screens/:id/copy", copyScreen);
|
router.post("/screens/:id/copy", copyScreen);
|
||||||
|
|
||||||
|
// 휴지통 관리
|
||||||
|
router.get("/screens/trash/list", getDeletedScreens); // 휴지통 화면 목록
|
||||||
|
router.post("/screens/:id/restore", restoreScreen); // 휴지통에서 복원
|
||||||
|
router.delete("/screens/:id/permanent", permanentDeleteScreen); // 영구 삭제
|
||||||
|
router.delete("/screens/trash/bulk", bulkPermanentDeleteScreens); // 일괄 영구 삭제
|
||||||
|
|
||||||
// 화면 코드 자동 생성
|
// 화면 코드 자동 생성
|
||||||
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
||||||
|
|
||||||
|
|
@ -48,4 +61,10 @@ router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
|
||||||
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
||||||
router.delete("/screens/:screenId/menus/:menuObjid", unassignScreenFromMenu);
|
router.delete("/screens/:screenId/menus/:menuObjid", unassignScreenFromMenu);
|
||||||
|
|
||||||
|
// 관리자용 정리 기능
|
||||||
|
router.post(
|
||||||
|
"/admin/cleanup-deleted-screen-menu-assignments",
|
||||||
|
cleanupDeletedScreenMenuAssignments
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,11 @@ export class ScreenManagementService {
|
||||||
console.log(`사용자 회사 코드:`, userCompanyCode);
|
console.log(`사용자 회사 코드:`, userCompanyCode);
|
||||||
|
|
||||||
// 화면 코드 중복 확인
|
// 화면 코드 중복 확인
|
||||||
const existingScreen = await prisma.screen_definitions.findUnique({
|
const existingScreen = await prisma.screen_definitions.findFirst({
|
||||||
where: { screen_code: screenData.screenCode },
|
where: {
|
||||||
|
screen_code: screenData.screenCode,
|
||||||
|
is_active: { not: "D" }, // 삭제되지 않은 화면만 중복 검사
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -79,15 +82,18 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회사별 화면 목록 조회 (페이징 지원)
|
* 회사별 화면 목록 조회 (페이징 지원) - 활성 화면만
|
||||||
*/
|
*/
|
||||||
async getScreensByCompany(
|
async getScreensByCompany(
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
size: number = 20
|
size: number = 20
|
||||||
): Promise<PaginatedResponse<ScreenDefinition>> {
|
): Promise<PaginatedResponse<ScreenDefinition>> {
|
||||||
const whereClause =
|
const whereClause: any = { is_active: { not: "D" } }; // 삭제된 화면 제외
|
||||||
companyCode === "*" ? {} : { company_code: companyCode };
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
whereClause.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
const [screens, total] = await Promise.all([
|
const [screens, total] = await Promise.all([
|
||||||
prisma.screen_definitions.findMany({
|
prisma.screen_definitions.findMany({
|
||||||
|
|
@ -111,11 +117,14 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 목록 조회 (간단 버전)
|
* 화면 목록 조회 (간단 버전) - 활성 화면만
|
||||||
*/
|
*/
|
||||||
async getScreens(companyCode: string): Promise<ScreenDefinition[]> {
|
async getScreens(companyCode: string): Promise<ScreenDefinition[]> {
|
||||||
const whereClause =
|
const whereClause: any = { is_active: { not: "D" } }; // 삭제된 화면 제외
|
||||||
companyCode === "*" ? {} : { company_code: companyCode };
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
whereClause.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
const screens = await prisma.screen_definitions.findMany({
|
const screens = await prisma.screen_definitions.findMany({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
|
|
@ -126,31 +135,37 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 정의 조회
|
* 화면 정의 조회 (활성 화면만)
|
||||||
*/
|
*/
|
||||||
async getScreenById(screenId: number): Promise<ScreenDefinition | null> {
|
async getScreenById(screenId: number): Promise<ScreenDefinition | null> {
|
||||||
const screen = await prisma.screen_definitions.findUnique({
|
const screen = await prisma.screen_definitions.findFirst({
|
||||||
where: { screen_id: screenId },
|
where: {
|
||||||
|
screen_id: screenId,
|
||||||
|
is_active: { not: "D" }, // 삭제된 화면 제외
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return screen ? this.mapToScreenDefinition(screen) : null;
|
return screen ? this.mapToScreenDefinition(screen) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 정의 조회 (회사 코드 검증 포함)
|
* 화면 정의 조회 (회사 코드 검증 포함, 활성 화면만)
|
||||||
*/
|
*/
|
||||||
async getScreen(
|
async getScreen(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
companyCode: string
|
companyCode: string
|
||||||
): Promise<ScreenDefinition | null> {
|
): Promise<ScreenDefinition | null> {
|
||||||
const whereClause: any = { screen_id: screenId };
|
const whereClause: any = {
|
||||||
|
screen_id: screenId,
|
||||||
|
is_active: { not: "D" }, // 삭제된 화면 제외
|
||||||
|
};
|
||||||
|
|
||||||
// 회사 코드가 '*'가 아닌 경우 회사별 필터링
|
// 회사 코드가 '*'가 아닌 경우 회사별 필터링
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereClause.company_code = companyCode;
|
whereClause.company_code = companyCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const screen = await prisma.screen_definitions.findUnique({
|
const screen = await prisma.screen_definitions.findFirst({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -196,9 +211,237 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 정의 삭제
|
* 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인
|
||||||
*/
|
*/
|
||||||
async deleteScreen(screenId: number, userCompanyCode: string): Promise<void> {
|
async checkScreenDependencies(
|
||||||
|
screenId: number,
|
||||||
|
userCompanyCode: string
|
||||||
|
): Promise<{
|
||||||
|
hasDependencies: boolean;
|
||||||
|
dependencies: Array<{
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
componentId: string;
|
||||||
|
componentType: string;
|
||||||
|
referenceType: string; // 'popup', 'navigate', 'targetScreen' 등
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
// 권한 확인
|
||||||
|
const targetScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetScreen) {
|
||||||
|
throw new Error("화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
userCompanyCode !== "*" &&
|
||||||
|
targetScreen.company_code !== "*" &&
|
||||||
|
targetScreen.company_code !== userCompanyCode
|
||||||
|
) {
|
||||||
|
throw new Error("이 화면에 접근할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 회사의 모든 활성 화면에서 이 화면을 참조하는지 확인
|
||||||
|
const whereClause = {
|
||||||
|
is_active: { not: "D" },
|
||||||
|
...(userCompanyCode !== "*" && {
|
||||||
|
company_code: { in: [userCompanyCode, "*"] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const allScreens = await prisma.screen_definitions.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: {
|
||||||
|
layouts: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dependencies: Array<{
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
componentId: string;
|
||||||
|
componentType: string;
|
||||||
|
referenceType: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 각 화면의 레이아웃에서 버튼 컴포넌트들을 검사
|
||||||
|
for (const screen of allScreens) {
|
||||||
|
if (screen.screen_id === screenId) continue; // 자기 자신은 제외
|
||||||
|
|
||||||
|
try {
|
||||||
|
// screen_layouts 테이블에서 버튼 컴포넌트 확인
|
||||||
|
const buttonLayouts = screen.layouts.filter(
|
||||||
|
(layout) => layout.component_type === "widget"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const layout of buttonLayouts) {
|
||||||
|
const properties = layout.properties as any;
|
||||||
|
|
||||||
|
// 버튼 컴포넌트인지 확인
|
||||||
|
if (properties?.widgetType === "button") {
|
||||||
|
const config = properties.webTypeConfig;
|
||||||
|
if (!config) continue;
|
||||||
|
|
||||||
|
// popup 액션에서 popupScreenId 확인
|
||||||
|
if (
|
||||||
|
config.actionType === "popup" &&
|
||||||
|
config.popupScreenId === screenId
|
||||||
|
) {
|
||||||
|
dependencies.push({
|
||||||
|
screenId: screen.screen_id,
|
||||||
|
screenName: screen.screen_name,
|
||||||
|
screenCode: screen.screen_code,
|
||||||
|
componentId: layout.component_id,
|
||||||
|
componentType: "button",
|
||||||
|
referenceType: "popup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigate 액션에서 navigateScreenId 확인
|
||||||
|
if (
|
||||||
|
config.actionType === "navigate" &&
|
||||||
|
config.navigateScreenId === screenId
|
||||||
|
) {
|
||||||
|
dependencies.push({
|
||||||
|
screenId: screen.screen_id,
|
||||||
|
screenName: screen.screen_name,
|
||||||
|
screenCode: screen.screen_code,
|
||||||
|
componentId: layout.component_id,
|
||||||
|
componentType: "button",
|
||||||
|
referenceType: "navigate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigateUrl에서 화면 ID 패턴 확인 (예: /screens/123)
|
||||||
|
if (
|
||||||
|
config.navigateUrl &&
|
||||||
|
config.navigateUrl.includes(`/screens/${screenId}`)
|
||||||
|
) {
|
||||||
|
dependencies.push({
|
||||||
|
screenId: screen.screen_id,
|
||||||
|
screenName: screen.screen_name,
|
||||||
|
screenCode: screen.screen_code,
|
||||||
|
componentId: layout.component_id,
|
||||||
|
componentType: "button",
|
||||||
|
referenceType: "url",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 layout_metadata도 확인 (하위 호환성)
|
||||||
|
const layoutMetadata = screen.layout_metadata as any;
|
||||||
|
if (layoutMetadata?.components) {
|
||||||
|
const components = layoutMetadata.components;
|
||||||
|
|
||||||
|
for (const component of components) {
|
||||||
|
// 버튼 컴포넌트인지 확인
|
||||||
|
if (
|
||||||
|
component.type === "widget" &&
|
||||||
|
component.widgetType === "button"
|
||||||
|
) {
|
||||||
|
const config = component.webTypeConfig;
|
||||||
|
if (!config) continue;
|
||||||
|
|
||||||
|
// popup 액션에서 targetScreenId 확인
|
||||||
|
if (
|
||||||
|
config.actionType === "popup" &&
|
||||||
|
config.targetScreenId === screenId
|
||||||
|
) {
|
||||||
|
dependencies.push({
|
||||||
|
screenId: screen.screen_id,
|
||||||
|
screenName: screen.screen_name,
|
||||||
|
screenCode: screen.screen_code,
|
||||||
|
componentId: component.id,
|
||||||
|
componentType: "button",
|
||||||
|
referenceType: "popup",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigate 액션에서 targetScreenId 확인
|
||||||
|
if (
|
||||||
|
config.actionType === "navigate" &&
|
||||||
|
config.targetScreenId === screenId
|
||||||
|
) {
|
||||||
|
dependencies.push({
|
||||||
|
screenId: screen.screen_id,
|
||||||
|
screenName: screen.screen_name,
|
||||||
|
screenCode: screen.screen_code,
|
||||||
|
componentId: component.id,
|
||||||
|
componentType: "button",
|
||||||
|
referenceType: "navigate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigateUrl에서 화면 ID 패턴 확인 (예: /screens/123)
|
||||||
|
if (
|
||||||
|
config.navigateUrl &&
|
||||||
|
config.navigateUrl.includes(`/screens/${screenId}`)
|
||||||
|
) {
|
||||||
|
dependencies.push({
|
||||||
|
screenId: screen.screen_id,
|
||||||
|
screenName: screen.screen_name,
|
||||||
|
screenCode: screen.screen_code,
|
||||||
|
componentId: component.id,
|
||||||
|
componentType: "button",
|
||||||
|
referenceType: "url",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`화면 ${screen.screen_id}의 레이아웃 분석 중 오류:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메뉴 할당 확인
|
||||||
|
const menuAssignments = await prisma.screen_menu_assignments.findMany({
|
||||||
|
where: {
|
||||||
|
screen_id: screenId,
|
||||||
|
is_active: "Y",
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
menu_info: true, // 메뉴 정보도 함께 조회
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴에 할당된 경우 의존성에 추가
|
||||||
|
for (const assignment of menuAssignments) {
|
||||||
|
dependencies.push({
|
||||||
|
screenId: 0, // 메뉴는 화면이 아니므로 0으로 설정
|
||||||
|
screenName: assignment.menu_info?.menu_name_kor || "알 수 없는 메뉴",
|
||||||
|
screenCode: `MENU_${assignment.menu_objid}`,
|
||||||
|
componentId: `menu_${assignment.assignment_id}`,
|
||||||
|
componentType: "menu",
|
||||||
|
referenceType: "menu_assignment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasDependencies: dependencies.length > 0,
|
||||||
|
dependencies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 정의 삭제 (휴지통으로 이동 - 소프트 삭제)
|
||||||
|
*/
|
||||||
|
async deleteScreen(
|
||||||
|
screenId: number,
|
||||||
|
userCompanyCode: string,
|
||||||
|
deletedBy: string,
|
||||||
|
deleteReason?: string,
|
||||||
|
force: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
const existingScreen = await prisma.screen_definitions.findUnique({
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
where: { screen_id: screenId },
|
where: { screen_id: screenId },
|
||||||
|
|
@ -215,11 +458,315 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면을 삭제할 권한이 없습니다.");
|
throw new Error("이 화면을 삭제할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이미 삭제된 화면인지 확인
|
||||||
|
if (existingScreen.is_active === "D") {
|
||||||
|
throw new Error("이미 삭제된 화면입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 강제 삭제가 아닌 경우 의존성 체크
|
||||||
|
if (!force) {
|
||||||
|
const dependencyCheck = await this.checkScreenDependencies(
|
||||||
|
screenId,
|
||||||
|
userCompanyCode
|
||||||
|
);
|
||||||
|
if (dependencyCheck.hasDependencies) {
|
||||||
|
const error = new Error("다른 화면에서 사용 중인 화면입니다.") as any;
|
||||||
|
error.code = "SCREEN_HAS_DEPENDENCIES";
|
||||||
|
error.dependencies = dependencyCheck.dependencies;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 소프트 삭제 (휴지통으로 이동)
|
||||||
|
await tx.screen_definitions.update({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
data: {
|
||||||
|
is_active: "D",
|
||||||
|
deleted_date: new Date(),
|
||||||
|
deleted_by: deletedBy,
|
||||||
|
delete_reason: deleteReason,
|
||||||
|
updated_date: new Date(),
|
||||||
|
updated_by: deletedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴 할당도 비활성화
|
||||||
|
await tx.screen_menu_assignments.updateMany({
|
||||||
|
where: {
|
||||||
|
screen_id: screenId,
|
||||||
|
is_active: "Y",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
is_active: "N",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 복원 (휴지통에서 복원)
|
||||||
|
*/
|
||||||
|
async restoreScreen(
|
||||||
|
screenId: number,
|
||||||
|
userCompanyCode: string,
|
||||||
|
restoredBy: string
|
||||||
|
): Promise<void> {
|
||||||
|
// 권한 확인
|
||||||
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingScreen) {
|
||||||
|
throw new Error("화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
userCompanyCode !== "*" &&
|
||||||
|
existingScreen.company_code !== userCompanyCode
|
||||||
|
) {
|
||||||
|
throw new Error("이 화면을 복원할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제된 화면이 아닌 경우
|
||||||
|
if (existingScreen.is_active !== "D") {
|
||||||
|
throw new Error("삭제된 화면이 아닙니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 코드 중복 확인 (복원 시 같은 코드가 이미 존재하는지)
|
||||||
|
const duplicateScreen = await prisma.screen_definitions.findFirst({
|
||||||
|
where: {
|
||||||
|
screen_code: existingScreen.screen_code,
|
||||||
|
is_active: { not: "D" },
|
||||||
|
screen_id: { not: screenId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateScreen) {
|
||||||
|
throw new Error(
|
||||||
|
"같은 화면 코드를 가진 활성 화면이 이미 존재합니다. 복원하려면 기존 화면의 코드를 변경하거나 삭제해주세요."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트랜잭션으로 화면 복원과 메뉴 할당 복원을 함께 처리
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 화면 복원
|
||||||
|
await tx.screen_definitions.update({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
data: {
|
||||||
|
is_active: "Y",
|
||||||
|
deleted_date: null,
|
||||||
|
deleted_by: null,
|
||||||
|
delete_reason: null,
|
||||||
|
updated_date: new Date(),
|
||||||
|
updated_by: restoredBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴 할당도 다시 활성화
|
||||||
|
await tx.screen_menu_assignments.updateMany({
|
||||||
|
where: {
|
||||||
|
screen_id: screenId,
|
||||||
|
is_active: "N",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
is_active: "Y",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴지통 화면들의 메뉴 할당 정리 (관리자용)
|
||||||
|
*/
|
||||||
|
async cleanupDeletedScreenMenuAssignments(): Promise<{
|
||||||
|
updatedCount: number;
|
||||||
|
message: string;
|
||||||
|
}> {
|
||||||
|
const result = await prisma.$executeRaw`
|
||||||
|
UPDATE screen_menu_assignments
|
||||||
|
SET is_active = 'N'
|
||||||
|
WHERE screen_id IN (
|
||||||
|
SELECT screen_id
|
||||||
|
FROM screen_definitions
|
||||||
|
WHERE is_active = 'D'
|
||||||
|
) AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedCount: Number(result),
|
||||||
|
message: `${result}개의 메뉴 할당이 정리되었습니다.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 영구 삭제 (휴지통에서 완전 삭제)
|
||||||
|
*/
|
||||||
|
async permanentDeleteScreen(
|
||||||
|
screenId: number,
|
||||||
|
userCompanyCode: string
|
||||||
|
): Promise<void> {
|
||||||
|
// 권한 확인
|
||||||
|
const existingScreen = await prisma.screen_definitions.findUnique({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingScreen) {
|
||||||
|
throw new Error("화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
userCompanyCode !== "*" &&
|
||||||
|
existingScreen.company_code !== userCompanyCode
|
||||||
|
) {
|
||||||
|
throw new Error("이 화면을 영구 삭제할 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제된 화면이 아닌 경우 영구 삭제 불가
|
||||||
|
if (existingScreen.is_active !== "D") {
|
||||||
|
throw new Error("휴지통에 있는 화면만 영구 삭제할 수 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 물리적 삭제 (CASCADE로 관련 레이아웃과 메뉴 할당도 함께 삭제됨)
|
||||||
await prisma.screen_definitions.delete({
|
await prisma.screen_definitions.delete({
|
||||||
where: { screen_id: screenId },
|
where: { screen_id: screenId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴지통 화면 목록 조회
|
||||||
|
*/
|
||||||
|
async getDeletedScreens(
|
||||||
|
companyCode: string,
|
||||||
|
page: number = 1,
|
||||||
|
size: number = 20
|
||||||
|
): Promise<
|
||||||
|
PaginatedResponse<
|
||||||
|
ScreenDefinition & {
|
||||||
|
deletedDate?: Date;
|
||||||
|
deletedBy?: string;
|
||||||
|
deleteReason?: string;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
const whereClause: any = { is_active: "D" };
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
whereClause.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [screens, total] = await Promise.all([
|
||||||
|
prisma.screen_definitions.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
skip: (page - 1) * size,
|
||||||
|
take: size,
|
||||||
|
orderBy: { deleted_date: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.screen_definitions.count({ where: whereClause }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: screens.map((screen) => ({
|
||||||
|
...this.mapToScreenDefinition(screen),
|
||||||
|
deletedDate: screen.deleted_date || undefined,
|
||||||
|
deletedBy: screen.deleted_by || undefined,
|
||||||
|
deleteReason: screen.delete_reason || undefined,
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / size),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴지통 화면 일괄 영구 삭제
|
||||||
|
*/
|
||||||
|
async bulkPermanentDeleteScreens(
|
||||||
|
screenIds: number[],
|
||||||
|
userCompanyCode: string
|
||||||
|
): Promise<{
|
||||||
|
deletedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errors: Array<{ screenId: number; error: string }>;
|
||||||
|
}> {
|
||||||
|
if (screenIds.length === 0) {
|
||||||
|
throw new Error("삭제할 화면을 선택해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 확인 - 해당 회사의 휴지통 화면들만 조회
|
||||||
|
const whereClause: any = {
|
||||||
|
screen_id: { in: screenIds },
|
||||||
|
is_active: "D", // 휴지통에 있는 화면만
|
||||||
|
};
|
||||||
|
|
||||||
|
if (userCompanyCode !== "*") {
|
||||||
|
whereClause.company_code = userCompanyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screensToDelete = await prisma.screen_definitions.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
|
let deletedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
const errors: Array<{ screenId: number; error: string }> = [];
|
||||||
|
|
||||||
|
// 각 화면을 개별적으로 삭제 처리
|
||||||
|
for (const screenId of screenIds) {
|
||||||
|
try {
|
||||||
|
const screenToDelete = screensToDelete.find(
|
||||||
|
(s) => s.screen_id === screenId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!screenToDelete) {
|
||||||
|
skippedCount++;
|
||||||
|
errors.push({
|
||||||
|
screenId,
|
||||||
|
error: "화면을 찾을 수 없거나 삭제 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관련 레이아웃 데이터도 함께 삭제
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// screen_layouts 삭제
|
||||||
|
await tx.screen_layouts.deleteMany({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// screen_menu_assignments 삭제
|
||||||
|
await tx.screen_menu_assignments.deleteMany({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// screen_definitions 삭제
|
||||||
|
await tx.screen_definitions.delete({
|
||||||
|
where: { screen_id: screenId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
deletedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
skippedCount++;
|
||||||
|
errors.push({
|
||||||
|
screenId,
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
console.error(`화면 ${screenId} 영구 삭제 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletedCount,
|
||||||
|
skippedCount,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 테이블 관리
|
// 테이블 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
### 환경변수 설정
|
||||||
|
|
||||||
|
개발 환경에서 파일 미리보기가 정상 작동하도록 하려면 다음 환경변수를 설정하세요:
|
||||||
|
|
||||||
|
1. `.env.local` 파일을 생성하고 다음 내용을 추가:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 개발 환경 (Next.js rewrites 사용)
|
||||||
|
NEXT_PUBLIC_API_URL=/api
|
||||||
|
|
||||||
|
# 운영 환경에서는 실제 백엔드 URL 사용
|
||||||
|
# NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 백엔드 서버가 포트 3000에서 실행되고 있는지 확인
|
||||||
|
3. Next.js 개발 서버는 포트 9771에서 실행
|
||||||
|
|
||||||
|
### 파일 미리보기 문제 해결
|
||||||
|
|
||||||
|
파일 미리보기에서 CORS 오류가 발생하는 경우:
|
||||||
|
|
||||||
|
1. 백엔드 서버가 정상 실행 중인지 확인
|
||||||
|
2. Next.js rewrites 설정이 올바른지 확인 (`next.config.mjs`)
|
||||||
|
3. 환경변수 `NEXT_PUBLIC_API_URL`이 올바르게 설정되었는지 확인
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
First, run the development server:
|
First, run the development server:
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||||
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
|
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { downloadFile, getLinkedFiles } from "@/lib/api/file";
|
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||||
|
|
||||||
|
|
@ -111,6 +111,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
|
const [imageLoadError, setImageLoadError] = useState(false);
|
||||||
|
const [alternativeImageUrl, setAlternativeImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
// 파일 관리 상태
|
// 파일 관리 상태
|
||||||
const [fileStatusMap, setFileStatusMap] = useState<Record<string, { hasFiles: boolean; fileCount: number }>>({}); // 행별 파일 상태
|
const [fileStatusMap, setFileStatusMap] = useState<Record<string, { hasFiles: boolean; fileCount: number }>>({}); // 행별 파일 상태
|
||||||
|
|
@ -224,6 +226,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setShowPreviewModal(true);
|
setShowPreviewModal(true);
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
setRotation(0);
|
setRotation(0);
|
||||||
|
setImageLoadError(false);
|
||||||
|
setAlternativeImageUrl(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closePreviewModal = useCallback(() => {
|
const closePreviewModal = useCallback(() => {
|
||||||
|
|
@ -231,6 +235,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setPreviewImage(null);
|
setPreviewImage(null);
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
setRotation(0);
|
setRotation(0);
|
||||||
|
setImageLoadError(false);
|
||||||
|
setAlternativeImageUrl(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleZoom = useCallback((direction: "in" | "out") => {
|
const handleZoom = useCallback((direction: "in" | "out") => {
|
||||||
|
|
@ -254,6 +260,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 이미지 로딩 실패 시 대체 URL 시도
|
||||||
|
const handleImageError = useCallback(() => {
|
||||||
|
if (!imageLoadError && previewImage) {
|
||||||
|
console.error("이미지 로딩 실패:", previewImage);
|
||||||
|
setImageLoadError(true);
|
||||||
|
|
||||||
|
// 대체 URL 생성 (직접 파일 경로 사용)
|
||||||
|
if (previewImage.path) {
|
||||||
|
const altUrl = getDirectFileUrl(previewImage.path);
|
||||||
|
console.log("대체 URL 시도:", altUrl);
|
||||||
|
setAlternativeImageUrl(altUrl);
|
||||||
|
} else {
|
||||||
|
toast.error("이미지를 불러올 수 없습니다.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error("이미지를 불러올 수 없습니다.");
|
||||||
|
}
|
||||||
|
}, [imageLoadError, previewImage]);
|
||||||
const [showFileModal, setShowFileModal] = useState(false);
|
const [showFileModal, setShowFileModal] = useState(false);
|
||||||
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
const [currentFileData, setCurrentFileData] = useState<FileColumnData | null>(null);
|
||||||
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
const [currentFileColumn, setCurrentFileColumn] = useState<DataTableColumn | null>(null);
|
||||||
|
|
@ -2248,16 +2273,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-gray-50 p-4">
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
<img
|
<img
|
||||||
src={`${process.env.NEXT_PUBLIC_API_URL}/files/preview/${previewImage.id}?serverFilename=${previewImage.savedFileName}`}
|
src={alternativeImageUrl || getFilePreviewUrl(previewImage.id)}
|
||||||
alt={previewImage.name}
|
alt={previewImage.name}
|
||||||
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
className="max-h-full max-w-full object-contain transition-transform duration-200"
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={handleImageError}
|
||||||
console.error("이미지 로딩 실패:", previewImage);
|
|
||||||
toast.error("이미지를 불러올 수 없습니다.");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
WebType,
|
WebType,
|
||||||
WidgetComponent,
|
WidgetComponent,
|
||||||
FileComponent,
|
FileComponent,
|
||||||
|
AreaComponent,
|
||||||
|
AreaLayoutType,
|
||||||
DateTypeConfig,
|
DateTypeConfig,
|
||||||
NumberTypeConfig,
|
NumberTypeConfig,
|
||||||
SelectTypeConfig,
|
SelectTypeConfig,
|
||||||
|
|
@ -50,6 +52,15 @@ import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
|
Square,
|
||||||
|
CreditCard,
|
||||||
|
Layout,
|
||||||
|
Grid3x3,
|
||||||
|
Columns,
|
||||||
|
Rows,
|
||||||
|
SidebarOpen,
|
||||||
|
Folder,
|
||||||
|
ChevronUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface RealtimePreviewProps {
|
interface RealtimePreviewProps {
|
||||||
|
|
@ -62,6 +73,159 @@ interface RealtimePreviewProps {
|
||||||
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
children?: React.ReactNode; // 그룹 내 자식 컴포넌트들
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 영역 레이아웃에 따른 아이콘 반환
|
||||||
|
const getAreaIcon = (layoutType: AreaLayoutType) => {
|
||||||
|
switch (layoutType) {
|
||||||
|
case "box":
|
||||||
|
return <Square className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "card":
|
||||||
|
return <CreditCard className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "panel":
|
||||||
|
return <Layout className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "section":
|
||||||
|
return <Layout className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "grid":
|
||||||
|
return <Grid3x3 className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "flex-row":
|
||||||
|
return <Columns className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "flex-column":
|
||||||
|
return <Rows className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "sidebar":
|
||||||
|
return <SidebarOpen className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "header-content":
|
||||||
|
return <Layout className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "tabs":
|
||||||
|
return <Folder className="h-6 w-6 text-blue-600" />;
|
||||||
|
case "accordion":
|
||||||
|
return <ChevronUp className="h-6 w-6 text-blue-600" />;
|
||||||
|
default:
|
||||||
|
return <Square className="h-6 w-6 text-blue-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 영역 컴포넌트 렌더링
|
||||||
|
const renderArea = (component: AreaComponent, children?: React.ReactNode) => {
|
||||||
|
const { layoutType, title, description, layoutConfig, areaStyle } = component;
|
||||||
|
|
||||||
|
// 기본 스타일
|
||||||
|
const baseStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: areaStyle?.backgroundColor || "#ffffff",
|
||||||
|
border: areaStyle?.borderWidth
|
||||||
|
? `${areaStyle.borderWidth}px ${areaStyle.borderStyle || "solid"} ${areaStyle.borderColor || "#e5e7eb"}`
|
||||||
|
: "1px solid #e5e7eb",
|
||||||
|
borderRadius: `${areaStyle?.borderRadius || 8}px`,
|
||||||
|
padding: `${areaStyle?.padding || 16}px`,
|
||||||
|
margin: `${areaStyle?.margin || 0}px`,
|
||||||
|
...(areaStyle?.shadow &&
|
||||||
|
areaStyle.shadow !== "none" && {
|
||||||
|
boxShadow:
|
||||||
|
{
|
||||||
|
sm: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
|
md: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||||
|
lg: "0 10px 15px -3px rgba(0, 0, 0, 0.1)",
|
||||||
|
xl: "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
|
||||||
|
}[areaStyle.shadow] || "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃별 컨테이너 스타일
|
||||||
|
const getLayoutStyle = (): React.CSSProperties => {
|
||||||
|
switch (layoutType) {
|
||||||
|
case "grid":
|
||||||
|
return {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${layoutConfig?.gridColumns || 3}, 1fr)`,
|
||||||
|
gridTemplateRows: layoutConfig?.gridRows ? `repeat(${layoutConfig.gridRows}, 1fr)` : "auto",
|
||||||
|
gap: `${layoutConfig?.gridGap || 16}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
case "flex-row":
|
||||||
|
return {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: layoutConfig?.justifyContent || "flex-start",
|
||||||
|
alignItems: layoutConfig?.alignItems || "stretch",
|
||||||
|
gap: `${layoutConfig?.gap || 16}px`,
|
||||||
|
flexWrap: layoutConfig?.flexWrap || "nowrap",
|
||||||
|
};
|
||||||
|
|
||||||
|
case "flex-column":
|
||||||
|
return {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: layoutConfig?.justifyContent || "flex-start",
|
||||||
|
alignItems: layoutConfig?.alignItems || "stretch",
|
||||||
|
gap: `${layoutConfig?.gap || 16}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
case "sidebar":
|
||||||
|
return {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: layoutConfig?.sidebarPosition === "right" ? "row-reverse" : "row",
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더 렌더링 (panel, section 타입용)
|
||||||
|
const renderHeader = () => {
|
||||||
|
if (!title || (layoutType !== "panel" && layoutType !== "section")) return null;
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: areaStyle?.headerBackgroundColor || "#f3f4f6",
|
||||||
|
color: areaStyle?.headerTextColor || "#374151",
|
||||||
|
height: `${areaStyle?.headerHeight || 48}px`,
|
||||||
|
padding: `${areaStyle?.headerPadding || 16}px`,
|
||||||
|
borderBottom: layoutType === "panel" ? "1px solid #e5e7eb" : "none",
|
||||||
|
borderTopLeftRadius: `${areaStyle?.borderRadius || 8}px`,
|
||||||
|
borderTopRightRadius: `${areaStyle?.borderRadius || 8}px`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
fontWeight: "600",
|
||||||
|
fontSize: "14px",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={headerStyle}>
|
||||||
|
{title}
|
||||||
|
{description && <span style={{ marginLeft: "8px", fontSize: "12px", opacity: 0.7 }}>{description}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컨텐츠 영역 스타일
|
||||||
|
const contentStyle: React.CSSProperties = {
|
||||||
|
...getLayoutStyle(),
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자식 컴포넌트가 없을 때 표시할 플레이스홀더
|
||||||
|
const renderPlaceholder = () => (
|
||||||
|
<div className="pointer-events-none flex h-full flex-col items-center justify-center p-4">
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
{getAreaIcon(layoutType)}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-medium text-gray-700">{title || `${layoutType} 영역`}</div>
|
||||||
|
<div className="text-xs text-gray-500">{description || "컴포넌트를 이 영역에 드래그하세요"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={baseStyle}>
|
||||||
|
{renderHeader()}
|
||||||
|
<div style={contentStyle}>{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 웹 타입에 따른 위젯 렌더링
|
// 웹 타입에 따른 위젯 렌더링
|
||||||
const renderWidget = (component: ComponentData) => {
|
const renderWidget = (component: ComponentData) => {
|
||||||
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
// 위젯 컴포넌트가 아닌 경우 빈 div 반환
|
||||||
|
|
@ -1193,6 +1357,17 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{type === "area" && (
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full"
|
||||||
|
data-area-container="true"
|
||||||
|
data-component-id={component.id}
|
||||||
|
data-layout-type={(component as AreaComponent).layoutType}
|
||||||
|
>
|
||||||
|
{renderArea(component as AreaComponent, children)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{false &&
|
{false &&
|
||||||
(() => {
|
(() => {
|
||||||
const dataTableComponent = component as any; // DataTableComponent 타입
|
const dataTableComponent = component as any; // DataTableComponent 타입
|
||||||
|
|
|
||||||
|
|
@ -1042,6 +1042,57 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
...templateComp.style,
|
...templateComp.style,
|
||||||
},
|
},
|
||||||
} as ComponentData;
|
} as ComponentData;
|
||||||
|
} else if (templateComp.type === "area") {
|
||||||
|
// 영역 컴포넌트 생성
|
||||||
|
const gridColumns = 6; // 기본값: 6컬럼 (50% 너비)
|
||||||
|
|
||||||
|
const calculatedSize =
|
||||||
|
currentGridInfo && layout.gridSettings?.snapToGrid
|
||||||
|
? (() => {
|
||||||
|
const newWidth = calculateWidthFromColumns(
|
||||||
|
gridColumns,
|
||||||
|
currentGridInfo,
|
||||||
|
layout.gridSettings as GridUtilSettings,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
width: newWidth,
|
||||||
|
height: templateComp.size.height,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: templateComp.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: componentId,
|
||||||
|
type: "area",
|
||||||
|
label: templateComp.label,
|
||||||
|
position: finalPosition,
|
||||||
|
size: calculatedSize,
|
||||||
|
gridColumns,
|
||||||
|
layoutType: (templateComp as any).layoutType || "box",
|
||||||
|
title: (templateComp as any).title || templateComp.label,
|
||||||
|
description: (templateComp as any).description,
|
||||||
|
layoutConfig: (templateComp as any).layoutConfig || {},
|
||||||
|
areaStyle: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: "#e5e7eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
margin: 0,
|
||||||
|
shadow: "sm",
|
||||||
|
...(templateComp as any).areaStyle,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
style: {
|
||||||
|
labelDisplay: true,
|
||||||
|
labelFontSize: "14px",
|
||||||
|
labelColor: "#374151",
|
||||||
|
labelFontWeight: "600",
|
||||||
|
labelMarginBottom: "8px",
|
||||||
|
...templateComp.style,
|
||||||
|
},
|
||||||
|
} as ComponentData;
|
||||||
} else {
|
} else {
|
||||||
// 위젯 컴포넌트
|
// 위젯 컴포넌트
|
||||||
const widgetType = templateComp.widgetType || "text";
|
const widgetType = templateComp.widgetType || "text";
|
||||||
|
|
@ -2715,8 +2766,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onDragStart={(e) => startComponentDrag(component, e)}
|
onDragStart={(e) => startComponentDrag(component, e)}
|
||||||
onDragEnd={endDrag}
|
onDragEnd={endDrag}
|
||||||
>
|
>
|
||||||
{/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */}
|
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 */}
|
||||||
{(component.type === "group" || component.type === "container") &&
|
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||||
layout.components
|
layout.components
|
||||||
.filter((child) => child.parentId === component.id)
|
.filter((child) => child.parentId === component.id)
|
||||||
.map((child) => {
|
.map((child) => {
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,27 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "lucide-react";
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import CreateScreenModal from "./CreateScreenModal";
|
import CreateScreenModal from "./CreateScreenModal";
|
||||||
|
|
@ -24,8 +38,16 @@ interface ScreenListProps {
|
||||||
onDesignScreen: (screen: ScreenDefinition) => void;
|
onDesignScreen: (screen: ScreenDefinition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeletedScreenDefinition = ScreenDefinition & {
|
||||||
|
deletedDate?: Date;
|
||||||
|
deletedBy?: string;
|
||||||
|
deleteReason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
|
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState("active");
|
||||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
@ -34,20 +56,56 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||||||
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
||||||
|
// 삭제 관련 상태
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [screenToDelete, setScreenToDelete] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [deleteReason, setDeleteReason] = useState("");
|
||||||
|
const [dependencies, setDependencies] = useState<
|
||||||
|
Array<{
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
componentId: string;
|
||||||
|
componentType: string;
|
||||||
|
referenceType: string;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
const [showDependencyWarning, setShowDependencyWarning] = useState(false);
|
||||||
|
const [checkingDependencies, setCheckingDependencies] = useState(false);
|
||||||
|
|
||||||
|
// 영구 삭제 관련 상태
|
||||||
|
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
|
||||||
|
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
|
||||||
|
|
||||||
|
// 일괄삭제 관련 상태
|
||||||
|
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
||||||
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||||
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
|
|
||||||
// 화면 목록 로드 (실제 API)
|
// 화면 목록 로드 (실제 API)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let abort = false;
|
let abort = false;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
if (activeTab === "active") {
|
||||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||||
if (abort) return;
|
if (abort) return;
|
||||||
// 응답 표준: { success, data, total }
|
|
||||||
setScreens(resp.data || []);
|
setScreens(resp.data || []);
|
||||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||||
|
} else if (activeTab === "trash") {
|
||||||
|
const resp = await screenApi.getDeletedScreens({ page: currentPage, size: 20 });
|
||||||
|
if (abort) return;
|
||||||
|
setDeletedScreens(resp.data || []);
|
||||||
|
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("화면 목록 조회 실패", e);
|
console.error("화면 목록 조회 실패", e);
|
||||||
|
if (activeTab === "active") {
|
||||||
setScreens([]);
|
setScreens([]);
|
||||||
|
} else {
|
||||||
|
setDeletedScreens([]);
|
||||||
|
}
|
||||||
setTotalPages(1);
|
setTotalPages(1);
|
||||||
} finally {
|
} finally {
|
||||||
if (!abort) setLoading(false);
|
if (!abort) setLoading(false);
|
||||||
|
|
@ -57,7 +115,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
return () => {
|
return () => {
|
||||||
abort = true;
|
abort = true;
|
||||||
};
|
};
|
||||||
}, [currentPage, searchTerm]);
|
}, [currentPage, searchTerm, activeTab]);
|
||||||
|
|
||||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
const filteredScreens = screens; // 서버 필터 기준 사용
|
||||||
|
|
||||||
|
|
@ -84,10 +142,151 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
console.log("편집:", screen);
|
console.log("편집:", screen);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (screen: ScreenDefinition) => {
|
const handleDelete = async (screen: ScreenDefinition) => {
|
||||||
if (confirm(`"${screen.screenName}" 화면을 삭제하시겠습니까?`)) {
|
setScreenToDelete(screen);
|
||||||
// 삭제 API 호출
|
setCheckingDependencies(true);
|
||||||
console.log("삭제:", screen);
|
|
||||||
|
try {
|
||||||
|
// 의존성 체크
|
||||||
|
const dependencyResult = await screenApi.checkScreenDependencies(screen.screenId);
|
||||||
|
|
||||||
|
if (dependencyResult.hasDependencies) {
|
||||||
|
setDependencies(dependencyResult.dependencies);
|
||||||
|
setShowDependencyWarning(true);
|
||||||
|
} else {
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("의존성 체크 실패:", error);
|
||||||
|
// 의존성 체크 실패 시에도 삭제 다이얼로그는 열어줌
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
} finally {
|
||||||
|
setCheckingDependencies(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async (force: boolean = false) => {
|
||||||
|
if (!screenToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await screenApi.deleteScreen(screenToDelete.screenId, deleteReason, force);
|
||||||
|
setScreens((prev) => prev.filter((s) => s.screenId !== screenToDelete.screenId));
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setShowDependencyWarning(false);
|
||||||
|
setScreenToDelete(null);
|
||||||
|
setDeleteReason("");
|
||||||
|
setDependencies([]);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("화면 삭제 실패:", error);
|
||||||
|
|
||||||
|
// 의존성 오류인 경우 경고창 표시
|
||||||
|
if (error.response?.status === 409 && error.response?.data?.code === "SCREEN_HAS_DEPENDENCIES") {
|
||||||
|
setDependencies(error.response.data.dependencies || []);
|
||||||
|
setShowDependencyWarning(true);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
} else {
|
||||||
|
alert("화면 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelDelete = () => {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setShowDependencyWarning(false);
|
||||||
|
setScreenToDelete(null);
|
||||||
|
setDeleteReason("");
|
||||||
|
setDependencies([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async (screen: DeletedScreenDefinition) => {
|
||||||
|
if (!confirm(`"${screen.screenName}" 화면을 복원하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await screenApi.restoreScreen(screen.screenId);
|
||||||
|
setDeletedScreens((prev) => prev.filter((s) => s.screenId !== screen.screenId));
|
||||||
|
// 활성 탭으로 이동하여 복원된 화면 확인
|
||||||
|
setActiveTab("active");
|
||||||
|
reloadScreens();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 복원 실패:", error);
|
||||||
|
alert("화면 복원에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePermanentDelete = (screen: DeletedScreenDefinition) => {
|
||||||
|
setScreenToPermanentDelete(screen);
|
||||||
|
setPermanentDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPermanentDelete = async () => {
|
||||||
|
if (!screenToPermanentDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await screenApi.permanentDeleteScreen(screenToPermanentDelete.screenId);
|
||||||
|
setDeletedScreens((prev) => prev.filter((s) => s.screenId !== screenToPermanentDelete.screenId));
|
||||||
|
setPermanentDeleteDialogOpen(false);
|
||||||
|
setScreenToPermanentDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 영구 삭제 실패:", error);
|
||||||
|
alert("화면 영구 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 체크박스 선택 처리
|
||||||
|
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedScreenIds((prev) => [...prev, screenId]);
|
||||||
|
} else {
|
||||||
|
setSelectedScreenIds((prev) => prev.filter((id) => id !== screenId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
||||||
|
} else {
|
||||||
|
setSelectedScreenIds([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 일괄삭제 실행
|
||||||
|
const handleBulkDelete = () => {
|
||||||
|
if (selectedScreenIds.length === 0) {
|
||||||
|
alert("삭제할 화면을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBulkDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBulkDelete = async () => {
|
||||||
|
if (selectedScreenIds.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setBulkDeleting(true);
|
||||||
|
const result = await screenApi.bulkPermanentDeleteScreens(selectedScreenIds);
|
||||||
|
|
||||||
|
// 삭제된 화면들을 목록에서 제거
|
||||||
|
setDeletedScreens((prev) => prev.filter((screen) => !selectedScreenIds.includes(screen.screenId)));
|
||||||
|
|
||||||
|
setSelectedScreenIds([]);
|
||||||
|
setBulkDeleteDialogOpen(false);
|
||||||
|
|
||||||
|
// 결과 메시지 표시
|
||||||
|
let message = `${result.deletedCount}개 화면이 영구 삭제되었습니다.`;
|
||||||
|
if (result.skippedCount > 0) {
|
||||||
|
message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`;
|
||||||
|
}
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("일괄 삭제 실패:", error);
|
||||||
|
alert("일괄 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setBulkDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -126,15 +325,28 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-80 pl-10"
|
className="w-80 pl-10"
|
||||||
|
disabled={activeTab === "trash"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => setIsCreateOpen(true)}>
|
<Button
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
disabled={activeTab === "trash"}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />새 화면 생성
|
<Plus className="mr-2 h-4 w-4" />새 화면 생성
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 화면 목록 테이블 */}
|
{/* 탭 구조 */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
||||||
|
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 활성 화면 탭 */}
|
||||||
|
<TabsContent value="active">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
|
@ -179,7 +391,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={screen.isActive === "Y" ? "default" : "secondary"}
|
variant={screen.isActive === "Y" ? "default" : "secondary"}
|
||||||
className={screen.isActive === "Y" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}
|
className={
|
||||||
|
screen.isActive === "Y" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{screen.isActive === "Y" ? "활성" : "비활성"}
|
{screen.isActive === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -212,9 +426,15 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
복사
|
복사
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleDelete(screen)} className="text-red-600">
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(screen)}
|
||||||
|
className="text-red-600"
|
||||||
|
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
삭제
|
{checkingDependencies && screenToDelete?.screenId === screen.screenId
|
||||||
|
? "확인 중..."
|
||||||
|
: "삭제"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -224,9 +444,113 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{filteredScreens.length === 0 && <div className="py-8 text-center text-gray-500">검색 결과가 없습니다.</div>}
|
{filteredScreens.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-gray-500">검색 결과가 없습니다.</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 휴지통 탭 */}
|
||||||
|
<TabsContent value="trash">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>휴지통 ({deletedScreens.length})</span>
|
||||||
|
{selectedScreenIds.length > 0 && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleBulkDelete} disabled={bulkDeleting}>
|
||||||
|
{bulkDeleting ? "삭제 중..." : `선택된 ${selectedScreenIds.length}개 영구삭제`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>화면명</TableHead>
|
||||||
|
<TableHead>화면 코드</TableHead>
|
||||||
|
<TableHead>테이블명</TableHead>
|
||||||
|
<TableHead>삭제일</TableHead>
|
||||||
|
<TableHead>삭제자</TableHead>
|
||||||
|
<TableHead>삭제 사유</TableHead>
|
||||||
|
<TableHead>작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{deletedScreens.map((screen) => (
|
||||||
|
<TableRow key={screen.screenId} className="hover:bg-gray-50">
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedScreenIds.includes(screen.screenId)}
|
||||||
|
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{screen.screenName}</div>
|
||||||
|
{screen.description && <div className="mt-1 text-sm text-gray-500">{screen.description}</div>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
{screen.screenCode}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-sm text-gray-600">{screen.tableName}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm text-gray-600">{screen.deletedDate?.toLocaleDateString()}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm text-gray-600">{screen.deletedBy}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="max-w-32 truncate text-sm text-gray-600" title={screen.deleteReason}>
|
||||||
|
{screen.deleteReason || "-"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRestore(screen)}
|
||||||
|
className="text-green-600 hover:text-green-700"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-1 h-3 w-3" />
|
||||||
|
복원
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePermanentDelete(screen)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash className="mr-1 h-3 w-3" />
|
||||||
|
영구삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{deletedScreens.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-gray-500">휴지통이 비어있습니다.</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|
@ -269,6 +593,160 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
sourceScreen={screenToCopy}
|
sourceScreen={screenToCopy}
|
||||||
onCopySuccess={handleCopySuccess}
|
onCopySuccess={handleCopySuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>화면 삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{screenToDelete?.screenName}" 화면을 휴지통으로 이동하시겠습니까?
|
||||||
|
<br />
|
||||||
|
휴지통에서 언제든지 복원할 수 있습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="deleteReason">삭제 사유 (선택사항)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="deleteReason"
|
||||||
|
placeholder="삭제 사유를 입력하세요..."
|
||||||
|
value={deleteReason}
|
||||||
|
onChange={(e) => setDeleteReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelDelete}>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => confirmDelete(false)} className="bg-red-600 hover:bg-red-700">
|
||||||
|
휴지통으로 이동
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 의존성 경고 다이얼로그 */}
|
||||||
|
<AlertDialog open={showDependencyWarning} onOpenChange={setShowDependencyWarning}>
|
||||||
|
<AlertDialogContent className="max-w-2xl">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-orange-600">⚠️ 화면 삭제 경고</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{screenToDelete?.screenName}" 화면이 다른 화면에서 사용 중입니다.
|
||||||
|
<br />이 화면을 삭제하면 아래 화면들의 버튼 기능이 작동하지 않을 수 있습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-gray-900">사용 중인 화면 목록:</h4>
|
||||||
|
{dependencies.map((dep, index) => (
|
||||||
|
<div key={index} className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{dep.screenName}</div>
|
||||||
|
<div className="text-sm text-gray-600">화면 코드: {dep.screenCode}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-medium text-orange-600">
|
||||||
|
{dep.referenceType === "popup" && "팝업 버튼"}
|
||||||
|
{dep.referenceType === "navigate" && "이동 버튼"}
|
||||||
|
{dep.referenceType === "url" && "URL 링크"}
|
||||||
|
{dep.referenceType === "menu_assignment" && "메뉴 할당"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{dep.referenceType === "menu_assignment" ? "메뉴" : "컴포넌트"}: {dep.componentId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="forceDeleteReason">삭제 사유 (필수)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="forceDeleteReason"
|
||||||
|
placeholder="강제 삭제 사유를 입력하세요..."
|
||||||
|
value={deleteReason}
|
||||||
|
onChange={(e) => setDeleteReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={handleCancelDelete}>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => confirmDelete(true)}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700"
|
||||||
|
disabled={!deleteReason.trim()}
|
||||||
|
>
|
||||||
|
강제 삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 영구 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={permanentDeleteDialogOpen} onOpenChange={setPermanentDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>영구 삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-red-600">
|
||||||
|
⚠️ "{screenToPermanentDelete?.screenName}" 화면을 영구적으로 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
||||||
|
<br />
|
||||||
|
모든 레이아웃 정보와 관련 데이터가 완전히 삭제됩니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => {
|
||||||
|
setPermanentDeleteDialogOpen(false);
|
||||||
|
setScreenToPermanentDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmPermanentDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
영구 삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 일괄삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-red-600">
|
||||||
|
⚠️ 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
||||||
|
<br />
|
||||||
|
모든 레이아웃 정보와 관련 데이터가 완전히 삭제됩니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => {
|
||||||
|
setBulkDeleteDialogOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={bulkDeleting}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmBulkDelete}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={bulkDeleting}
|
||||||
|
>
|
||||||
|
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}개 영구 삭제`}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1520,11 +1520,13 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium">테이블 컬럼 설정</h3>
|
<h3 className="text-sm font-medium">테이블 컬럼 설정</h3>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Badge variant="secondary">{component.columns.length}개</Badge>
|
<Badge variant="secondary">{component.columns.length}개</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* 파일 컬럼 추가 버튼 */}
|
{/* 파일 컬럼 추가 버튼 */}
|
||||||
<Button size="sm" variant="outline" onClick={addVirtualFileColumn} className="h-8 text-xs">
|
<Button size="sm" variant="outline" onClick={addVirtualFileColumn} className="h-8 text-xs">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,16 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
||||||
import { ComponentData, WebType, WidgetComponent, GroupComponent, DataTableComponent, TableInfo } from "@/types/screen";
|
import {
|
||||||
|
ComponentData,
|
||||||
|
WebType,
|
||||||
|
WidgetComponent,
|
||||||
|
GroupComponent,
|
||||||
|
DataTableComponent,
|
||||||
|
AreaComponent,
|
||||||
|
AreaLayoutType,
|
||||||
|
TableInfo,
|
||||||
|
} from "@/types/screen";
|
||||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||||
|
|
||||||
interface PropertiesPanelProps {
|
interface PropertiesPanelProps {
|
||||||
|
|
@ -64,7 +73,13 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
// 입력 필드들의 로컬 상태 (실시간 타이핑 반영용)
|
// 입력 필드들의 로컬 상태 (실시간 타이핑 반영용)
|
||||||
const [localInputs, setLocalInputs] = useState({
|
const [localInputs, setLocalInputs] = useState({
|
||||||
placeholder: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).placeholder : "") || "",
|
placeholder: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).placeholder : "") || "",
|
||||||
title: (selectedComponent?.type === "group" ? (selectedComponent as GroupComponent).title : "") || "",
|
title:
|
||||||
|
(selectedComponent?.type === "group"
|
||||||
|
? (selectedComponent as GroupComponent).title
|
||||||
|
: selectedComponent?.type === "area"
|
||||||
|
? (selectedComponent as AreaComponent).title
|
||||||
|
: "") || "",
|
||||||
|
description: (selectedComponent?.type === "area" ? (selectedComponent as AreaComponent).description : "") || "",
|
||||||
positionX: selectedComponent?.position.x?.toString() || "0",
|
positionX: selectedComponent?.position.x?.toString() || "0",
|
||||||
positionY: selectedComponent?.position.y?.toString() || "0",
|
positionY: selectedComponent?.position.y?.toString() || "0",
|
||||||
positionZ: selectedComponent?.position.z?.toString() || "1",
|
positionZ: selectedComponent?.position.z?.toString() || "1",
|
||||||
|
|
@ -90,13 +105,15 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
if (selectedComponent) {
|
if (selectedComponent) {
|
||||||
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
|
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
|
||||||
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
|
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
|
||||||
|
const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null;
|
||||||
|
|
||||||
console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
|
console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
|
||||||
componentId: selectedComponent.id,
|
componentId: selectedComponent.id,
|
||||||
componentType: selectedComponent.type,
|
componentType: selectedComponent.type,
|
||||||
currentValues: {
|
currentValues: {
|
||||||
placeholder: widget?.placeholder,
|
placeholder: widget?.placeholder,
|
||||||
title: group?.title,
|
title: group?.title || area?.title,
|
||||||
|
description: area?.description,
|
||||||
positionX: selectedComponent.position.x,
|
positionX: selectedComponent.position.x,
|
||||||
labelText: selectedComponent.style?.labelText || selectedComponent.label,
|
labelText: selectedComponent.style?.labelText || selectedComponent.label,
|
||||||
},
|
},
|
||||||
|
|
@ -104,7 +121,8 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
|
|
||||||
setLocalInputs({
|
setLocalInputs({
|
||||||
placeholder: widget?.placeholder || "",
|
placeholder: widget?.placeholder || "",
|
||||||
title: group?.title || "",
|
title: group?.title || area?.title || "",
|
||||||
|
description: area?.description || "",
|
||||||
positionX: selectedComponent.position.x?.toString() || "0",
|
positionX: selectedComponent.position.x?.toString() || "0",
|
||||||
positionY: selectedComponent.position.y?.toString() || "0",
|
positionY: selectedComponent.position.y?.toString() || "0",
|
||||||
positionZ: selectedComponent.position.z?.toString() || "1",
|
positionZ: selectedComponent.position.z?.toString() || "1",
|
||||||
|
|
@ -644,6 +662,190 @@ export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedComponent.type === "area" && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 영역 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Settings className="h-4 w-4 text-gray-600" />
|
||||||
|
<h4 className="font-medium text-gray-900">영역 설정</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="areaTitle" className="text-sm font-medium">
|
||||||
|
영역 제목
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="areaTitle"
|
||||||
|
value={localInputs.title}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, title: newValue }));
|
||||||
|
onUpdateProperty("title", newValue);
|
||||||
|
}}
|
||||||
|
placeholder="영역 제목"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="areaDescription" className="text-sm font-medium">
|
||||||
|
영역 설명
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="areaDescription"
|
||||||
|
value={localInputs.description}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, description: newValue }));
|
||||||
|
onUpdateProperty("description", newValue);
|
||||||
|
}}
|
||||||
|
placeholder="영역 설명 (선택사항)"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="layoutType" className="text-sm font-medium">
|
||||||
|
레이아웃 타입
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={(selectedComponent as AreaComponent).layoutType}
|
||||||
|
onValueChange={(value: AreaLayoutType) => onUpdateProperty("layoutType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="box">기본 박스</SelectItem>
|
||||||
|
<SelectItem value="card">카드</SelectItem>
|
||||||
|
<SelectItem value="panel">패널 (헤더 포함)</SelectItem>
|
||||||
|
<SelectItem value="section">섹션</SelectItem>
|
||||||
|
<SelectItem value="grid">그리드</SelectItem>
|
||||||
|
<SelectItem value="flex-row">가로 플렉스</SelectItem>
|
||||||
|
<SelectItem value="flex-column">세로 플렉스</SelectItem>
|
||||||
|
<SelectItem value="sidebar">사이드바</SelectItem>
|
||||||
|
<SelectItem value="header-content">헤더-컨텐츠</SelectItem>
|
||||||
|
<SelectItem value="tabs">탭</SelectItem>
|
||||||
|
<SelectItem value="accordion">아코디언</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃별 상세 설정 */}
|
||||||
|
{(selectedComponent as AreaComponent).layoutType === "grid" && (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<h5 className="text-sm font-medium">그리드 설정</h5>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">컬럼 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="12"
|
||||||
|
value={(selectedComponent as AreaComponent).layoutConfig?.gridColumns || 3}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
onUpdateProperty("layoutConfig.gridColumns", value);
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">간격 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={(selectedComponent as AreaComponent).layoutConfig?.gridGap || 16}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
onUpdateProperty("layoutConfig.gridGap", value);
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{((selectedComponent as AreaComponent).layoutType === "flex-row" ||
|
||||||
|
(selectedComponent as AreaComponent).layoutType === "flex-column") && (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<h5 className="text-sm font-medium">플렉스 설정</h5>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">정렬 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={(selectedComponent as AreaComponent).layoutConfig?.justifyContent || "flex-start"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("layoutConfig.justifyContent", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="flex-start">시작</SelectItem>
|
||||||
|
<SelectItem value="flex-end">끝</SelectItem>
|
||||||
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
|
<SelectItem value="space-between">양끝 정렬</SelectItem>
|
||||||
|
<SelectItem value="space-around">균등 분배</SelectItem>
|
||||||
|
<SelectItem value="space-evenly">균등 간격</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">간격 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={(selectedComponent as AreaComponent).layoutConfig?.gap || 16}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
onUpdateProperty("layoutConfig.gap", value);
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(selectedComponent as AreaComponent).layoutType === "sidebar" && (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<h5 className="text-sm font-medium">사이드바 설정</h5>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">사이드바 위치</Label>
|
||||||
|
<Select
|
||||||
|
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarPosition || "left"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("layoutConfig.sidebarPosition", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="left">왼쪽</SelectItem>
|
||||||
|
<SelectItem value="right">오른쪽</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">사이드바 너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarWidth || 200}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
onUpdateProperty("layoutConfig.sidebarWidth", value);
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,14 @@ import {
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Settings,
|
Settings,
|
||||||
Upload,
|
Upload,
|
||||||
|
Square,
|
||||||
|
CreditCard,
|
||||||
|
Layout,
|
||||||
|
Columns,
|
||||||
|
Rows,
|
||||||
|
SidebarOpen,
|
||||||
|
Folder,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// 템플릿 컴포넌트 타입 정의
|
// 템플릿 컴포넌트 타입 정의
|
||||||
|
|
@ -30,11 +38,11 @@ export interface TemplateComponent {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: "table" | "button" | "form" | "layout" | "chart" | "status" | "file";
|
category: "table" | "button" | "form" | "layout" | "chart" | "status" | "file" | "area";
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
defaultSize: { width: number; height: number };
|
defaultSize: { width: number; height: number };
|
||||||
components: Array<{
|
components: Array<{
|
||||||
type: "widget" | "container" | "datatable" | "file";
|
type: "widget" | "container" | "datatable" | "file" | "area";
|
||||||
widgetType?: string;
|
widgetType?: string;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
@ -43,6 +51,13 @@ export interface TemplateComponent {
|
||||||
style?: any;
|
style?: any;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
parentId?: string;
|
||||||
|
title?: string;
|
||||||
|
// 영역 컴포넌트 전용 속성
|
||||||
|
layoutType?: string;
|
||||||
|
description?: string;
|
||||||
|
layoutConfig?: any;
|
||||||
|
areaStyle?: any;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +138,303 @@ const templateComponents: TemplateComponent[] = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// === 영역 템플릿들 ===
|
||||||
|
|
||||||
|
// 기본 박스 영역
|
||||||
|
{
|
||||||
|
id: "area-box",
|
||||||
|
name: "기본 박스 영역",
|
||||||
|
description: "컴포넌트들을 그룹화할 수 있는 기본 박스 형태의 영역",
|
||||||
|
category: "area",
|
||||||
|
icon: <Square className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 400, height: 300 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "박스 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 400, height: 300 },
|
||||||
|
layoutType: "box",
|
||||||
|
title: "박스 영역",
|
||||||
|
description: "컴포넌트들을 그룹화할 수 있는 기본 박스",
|
||||||
|
layoutConfig: {},
|
||||||
|
areaStyle: {
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: "#d1d5db",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
margin: 0,
|
||||||
|
shadow: "none",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
padding: "16px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 카드 영역
|
||||||
|
{
|
||||||
|
id: "area-card",
|
||||||
|
name: "카드 영역",
|
||||||
|
description: "그림자와 둥근 모서리가 있는 카드 형태의 영역",
|
||||||
|
category: "area",
|
||||||
|
icon: <CreditCard className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 400, height: 300 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "카드 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 400, height: 300 },
|
||||||
|
layoutType: "card",
|
||||||
|
title: "카드 영역",
|
||||||
|
description: "그림자와 둥근 모서리가 있는 카드 형태",
|
||||||
|
layoutConfig: {},
|
||||||
|
areaStyle: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderWidth: 0,
|
||||||
|
borderStyle: "none",
|
||||||
|
borderColor: "#e5e7eb",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 20,
|
||||||
|
margin: 0,
|
||||||
|
shadow: "md",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "12px",
|
||||||
|
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||||
|
padding: "20px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 패널 영역 (헤더 포함)
|
||||||
|
{
|
||||||
|
id: "area-panel",
|
||||||
|
name: "패널 영역",
|
||||||
|
description: "제목 헤더가 포함된 패널 형태의 영역",
|
||||||
|
category: "area",
|
||||||
|
icon: <Layout className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 500, height: 400 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "패널 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 500, height: 400 },
|
||||||
|
layoutType: "panel",
|
||||||
|
title: "패널 제목",
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
headerBackgroundColor: "#f3f4f6",
|
||||||
|
headerHeight: 48,
|
||||||
|
headerPadding: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그리드 영역
|
||||||
|
{
|
||||||
|
id: "area-grid",
|
||||||
|
name: "그리드 영역",
|
||||||
|
description: "내부 컴포넌트들을 격자 형태로 배치하는 영역",
|
||||||
|
category: "area",
|
||||||
|
icon: <Grid3x3 className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 600, height: 400 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "그리드 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 600, height: 400 },
|
||||||
|
layoutType: "grid",
|
||||||
|
title: "그리드 영역",
|
||||||
|
description: "격자 형태로 컴포넌트 배치",
|
||||||
|
layoutConfig: {
|
||||||
|
gridColumns: 3,
|
||||||
|
gridRows: 2,
|
||||||
|
gridGap: 16,
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: "#d1d5db",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
margin: 0,
|
||||||
|
shadow: "none",
|
||||||
|
showGridLines: true,
|
||||||
|
gridLineColor: "#e5e7eb",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "16px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 가로 플렉스 영역
|
||||||
|
{
|
||||||
|
id: "area-flex-row",
|
||||||
|
name: "가로 배치 영역",
|
||||||
|
description: "내부 컴포넌트들을 가로로 나란히 배치하는 영역",
|
||||||
|
category: "area",
|
||||||
|
icon: <Columns className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 600, height: 200 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "가로 배치 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 600, height: 200 },
|
||||||
|
layoutType: "flex-row",
|
||||||
|
layoutConfig: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
border: "1px solid #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "16px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 세로 플렉스 영역
|
||||||
|
{
|
||||||
|
id: "area-flex-column",
|
||||||
|
name: "세로 배치 영역",
|
||||||
|
description: "내부 컴포넌트들을 세로로 순차 배치하는 영역",
|
||||||
|
category: "area",
|
||||||
|
icon: <Rows className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 300, height: 500 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "세로 배치 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 300, height: 500 },
|
||||||
|
layoutType: "flex-column",
|
||||||
|
layoutConfig: {
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "stretch",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#f1f5f9",
|
||||||
|
border: "1px solid #94a3b8",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "16px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 사이드바 영역
|
||||||
|
{
|
||||||
|
id: "area-sidebar",
|
||||||
|
name: "사이드바 영역",
|
||||||
|
description: "사이드바와 메인 컨텐츠 영역으로 구분된 레이아웃",
|
||||||
|
category: "area",
|
||||||
|
icon: <SidebarOpen className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 700, height: 400 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "사이드바 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 700, height: 400 },
|
||||||
|
layoutType: "sidebar",
|
||||||
|
layoutConfig: {
|
||||||
|
sidebarPosition: "left",
|
||||||
|
sidebarWidth: 200,
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 탭 영역
|
||||||
|
{
|
||||||
|
id: "area-tabs",
|
||||||
|
name: "탭 영역",
|
||||||
|
description: "탭으로 구분된 여러 컨텐츠 영역을 제공하는 레이아웃",
|
||||||
|
category: "area",
|
||||||
|
icon: <Folder className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 600, height: 400 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "탭 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 600, height: 400 },
|
||||||
|
layoutType: "tabs",
|
||||||
|
layoutConfig: {
|
||||||
|
tabPosition: "top",
|
||||||
|
defaultActiveTab: "tab1",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 아코디언 영역
|
||||||
|
{
|
||||||
|
id: "area-accordion",
|
||||||
|
name: "아코디언 영역",
|
||||||
|
description: "접고 펼칠 수 있는 섹션들로 구성된 영역",
|
||||||
|
category: "area",
|
||||||
|
icon: <ChevronDown className="h-4 w-4" />,
|
||||||
|
defaultSize: { width: 500, height: 600 },
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "area",
|
||||||
|
label: "아코디언 영역",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 500, height: 600 },
|
||||||
|
layoutType: "accordion",
|
||||||
|
layoutConfig: {
|
||||||
|
allowMultiple: false,
|
||||||
|
defaultExpanded: ["section1"],
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface TemplatesPanelProps {
|
interface TemplatesPanelProps {
|
||||||
|
|
@ -135,6 +447,7 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
|
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
|
||||||
|
{ id: "area", name: "영역", icon: <Layout className="h-4 w-4" /> },
|
||||||
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
|
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
|
||||||
{ id: "button", name: "버튼", icon: <MousePointer className="h-4 w-4" /> },
|
{ id: "button", name: "버튼", icon: <MousePointer className="h-4 w-4" /> },
|
||||||
{ id: "file", name: "파일", icon: <Upload className="h-4 w-4" /> },
|
{ id: "file", name: "파일", icon: <Upload className="h-4 w-4" /> },
|
||||||
|
|
|
||||||
|
|
@ -170,3 +170,27 @@ export const getLinkedFiles = async (
|
||||||
throw new Error("연결된 파일 조회에 실패했습니다.");
|
throw new Error("연결된 파일 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 미리보기 URL 생성
|
||||||
|
*/
|
||||||
|
export const getFilePreviewUrl = (fileId: string): string => {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||||||
|
return `${baseUrl}/files/preview/${fileId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 다운로드 URL 생성
|
||||||
|
*/
|
||||||
|
export const getFileDownloadUrl = (fileId: string): string => {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||||||
|
return `${baseUrl}/files/download/${fileId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 직접 파일 경로 URL 생성 (정적 파일 서빙)
|
||||||
|
*/
|
||||||
|
export const getDirectFileUrl = (filePath: string): string => {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || "";
|
||||||
|
return `${baseUrl}${filePath}`;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,78 @@ export const screenApi = {
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 화면 삭제
|
// 화면 의존성 체크
|
||||||
deleteScreen: async (screenId: number): Promise<void> => {
|
checkScreenDependencies: async (
|
||||||
await apiClient.delete(`/screen-management/screens/${screenId}`);
|
screenId: number,
|
||||||
|
): Promise<{
|
||||||
|
hasDependencies: boolean;
|
||||||
|
dependencies: Array<{
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
componentId: string;
|
||||||
|
componentType: string;
|
||||||
|
referenceType: string;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(`/screen-management/screens/${screenId}/dependencies`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 화면 삭제 (휴지통으로 이동)
|
||||||
|
deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => {
|
||||||
|
await apiClient.delete(`/screen-management/screens/${screenId}`, {
|
||||||
|
data: { deleteReason, force },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 휴지통 화면 목록 조회
|
||||||
|
getDeletedScreens: async (params: {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<
|
||||||
|
PaginatedResponse<ScreenDefinition & { deletedDate?: Date; deletedBy?: string; deleteReason?: string }>
|
||||||
|
> => {
|
||||||
|
const response = await apiClient.get("/screen-management/screens/trash/list", { params });
|
||||||
|
const raw = response.data || {};
|
||||||
|
const items: any[] = (raw.data ?? raw.items ?? []) as any[];
|
||||||
|
const mapped = items.map((it) => ({
|
||||||
|
...it,
|
||||||
|
createdDate: it.createdDate ? new Date(it.createdDate) : undefined,
|
||||||
|
updatedDate: it.updatedDate ? new Date(it.updatedDate) : undefined,
|
||||||
|
deletedDate: it.deletedDate ? new Date(it.deletedDate) : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const page = raw.page ?? params.page ?? 1;
|
||||||
|
const size = raw.size ?? params.size ?? mapped.length;
|
||||||
|
const total = raw.total ?? mapped.length;
|
||||||
|
const totalPages = raw.totalPages ?? Math.max(1, Math.ceil(total / (size || 1)));
|
||||||
|
|
||||||
|
return { data: mapped, total, page, size, totalPages };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 휴지통 화면 일괄 영구 삭제
|
||||||
|
bulkPermanentDeleteScreens: async (
|
||||||
|
screenIds: number[],
|
||||||
|
): Promise<{
|
||||||
|
deletedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errors: Array<{ screenId: number; error: string }>;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.delete("/screen-management/screens/trash/bulk", {
|
||||||
|
data: { screenIds },
|
||||||
|
});
|
||||||
|
return response.data.result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 화면 복원 (휴지통에서 복원)
|
||||||
|
restoreScreen: async (screenId: number): Promise<void> => {
|
||||||
|
await apiClient.post(`/screen-management/screens/${screenId}/restore`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 화면 영구 삭제
|
||||||
|
permanentDeleteScreen: async (screenId: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/screen-management/screens/${screenId}/permanent`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 화면 레이아웃 저장
|
// 화면 레이아웃 저장
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,13 @@ const nextConfig = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
|
// 개발 환경과 운영 환경에 따른 백엔드 URL 설정
|
||||||
|
const backendUrl = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "http://backend:8080";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
destination: "http://backend:8080/api/:path*",
|
destination: `${backendUrl}/api/:path*`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
@ -43,7 +46,8 @@ const nextConfig = {
|
||||||
|
|
||||||
// 환경 변수 (런타임에 읽기)
|
// 환경 변수 (런타임에 읽기)
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://39.117.244.52:8080/api",
|
// 개발 환경에서는 Next.js rewrites를 통해 /api로 프록시
|
||||||
|
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "/api",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// 화면관리 시스템 타입 정의
|
// 화면관리 시스템 타입 정의
|
||||||
|
|
||||||
// 기본 컴포넌트 타입
|
// 기본 컴포넌트 타입
|
||||||
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable" | "file";
|
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable" | "file" | "area";
|
||||||
|
|
||||||
// 웹 타입 정의
|
// 웹 타입 정의
|
||||||
export type WebType =
|
export type WebType =
|
||||||
|
|
@ -208,6 +208,80 @@ export interface ColumnComponent extends BaseComponent {
|
||||||
children?: string[]; // 자식 컴포넌트 ID 목록
|
children?: string[]; // 자식 컴포넌트 ID 목록
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 영역 레이아웃 타입
|
||||||
|
export type AreaLayoutType =
|
||||||
|
| "box" // 기본 박스
|
||||||
|
| "card" // 카드 형태 (그림자 + 둥근 모서리)
|
||||||
|
| "panel" // 패널 형태 (헤더 포함)
|
||||||
|
| "section" // 섹션 형태 (제목 + 구분선)
|
||||||
|
| "grid" // 그리드 레이아웃
|
||||||
|
| "flex-row" // 가로 플렉스
|
||||||
|
| "flex-column" // 세로 플렉스
|
||||||
|
| "sidebar" // 사이드바 레이아웃
|
||||||
|
| "header-content" // 헤더-컨텐츠 레이아웃
|
||||||
|
| "tabs" // 탭 레이아웃
|
||||||
|
| "accordion"; // 아코디언 레이아웃
|
||||||
|
|
||||||
|
// 영역 컴포넌트
|
||||||
|
export interface AreaComponent extends BaseComponent {
|
||||||
|
type: "area";
|
||||||
|
layoutType: AreaLayoutType;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// 레이아웃별 설정
|
||||||
|
layoutConfig?: {
|
||||||
|
// 그리드 레이아웃 설정
|
||||||
|
gridColumns?: number;
|
||||||
|
gridRows?: number;
|
||||||
|
gridGap?: number;
|
||||||
|
|
||||||
|
// 플렉스 레이아웃 설정
|
||||||
|
flexDirection?: "row" | "column";
|
||||||
|
flexWrap?: "nowrap" | "wrap" | "wrap-reverse";
|
||||||
|
justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
|
||||||
|
alignItems?: "stretch" | "flex-start" | "flex-end" | "center" | "baseline";
|
||||||
|
gap?: number;
|
||||||
|
|
||||||
|
// 탭 레이아웃 설정
|
||||||
|
tabPosition?: "top" | "bottom" | "left" | "right";
|
||||||
|
defaultActiveTab?: string;
|
||||||
|
|
||||||
|
// 사이드바 레이아웃 설정
|
||||||
|
sidebarPosition?: "left" | "right";
|
||||||
|
sidebarWidth?: number;
|
||||||
|
collapsible?: boolean;
|
||||||
|
|
||||||
|
// 아코디언 설정
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
defaultExpanded?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스타일 설정
|
||||||
|
areaStyle?: {
|
||||||
|
backgroundColor?: string;
|
||||||
|
borderColor?: string;
|
||||||
|
borderWidth?: number;
|
||||||
|
borderStyle?: "solid" | "dashed" | "dotted" | "none";
|
||||||
|
borderRadius?: number;
|
||||||
|
padding?: number;
|
||||||
|
margin?: number;
|
||||||
|
shadow?: "none" | "sm" | "md" | "lg" | "xl";
|
||||||
|
|
||||||
|
// 헤더 스타일 (panel, section 타입용)
|
||||||
|
headerBackgroundColor?: string;
|
||||||
|
headerTextColor?: string;
|
||||||
|
headerHeight?: number;
|
||||||
|
headerPadding?: number;
|
||||||
|
|
||||||
|
// 그리드 라인 표시 (grid 타입용)
|
||||||
|
showGridLines?: boolean;
|
||||||
|
gridLineColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
children?: string[]; // 자식 컴포넌트 ID 목록
|
||||||
|
}
|
||||||
|
|
||||||
// 파일 첨부 컴포넌트
|
// 파일 첨부 컴포넌트
|
||||||
export interface FileComponent extends BaseComponent {
|
export interface FileComponent extends BaseComponent {
|
||||||
type: "file";
|
type: "file";
|
||||||
|
|
@ -411,6 +485,7 @@ export type ComponentData =
|
||||||
| GroupComponent
|
| GroupComponent
|
||||||
| RowComponent
|
| RowComponent
|
||||||
| ColumnComponent
|
| ColumnComponent
|
||||||
|
| AreaComponent
|
||||||
| WidgetComponent
|
| WidgetComponent
|
||||||
| DataTableComponent
|
| DataTableComponent
|
||||||
| FileComponent;
|
| FileComponent;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue