화면관리 삭제기능구현

This commit is contained in:
kjs 2025-09-08 13:10:09 +09:00
parent 87ce1b74d4
commit 1eeda775ef
19 changed files with 2506 additions and 167 deletions

View File

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

View File

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

View File

@ -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: "파일 미리보기 중 오류가 발생했습니다.",
});
}
};
/** /**
* *
*/ */

View File

@ -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: "메뉴 할당 정리에 실패했습니다.",
});
}
};

View File

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

View File

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

View File

@ -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,
};
}
// ======================================== // ========================================
// 테이블 관리 // 테이블 관리
// ======================================== // ========================================

View File

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

View File

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

View File

@ -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 타입

View File

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

View File

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

View File

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

View File

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

View File

@ -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" /> },

View File

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

View File

@ -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`);
}, },
// 화면 레이아웃 저장 // 화면 레이아웃 저장

View File

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

View File

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