From 1eeda775ef7d6d066a44aefd94721205d25dd875 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 8 Sep 2025 13:10:09 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EA=B8=B0=EB=8A=A5=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/prisma/schema.prisma | 43 +- backend-node/src/app.ts | 18 + .../src/controllers/fileController.ts | 119 +++ .../controllers/screenManagementController.ts | 187 ++++- backend-node/src/routes/fileRoutes.ts | 8 + .../src/routes/screenManagementRoutes.ts | 21 +- .../src/services/screenManagementService.ts | 579 ++++++++++++++- frontend/README.md | 27 + .../screen/InteractiveDataTable.tsx | 34 +- .../components/screen/RealtimePreview.tsx | 175 +++++ frontend/components/screen/ScreenDesigner.tsx | 55 +- frontend/components/screen/ScreenList.tsx | 688 +++++++++++++++--- .../screen/panels/DataTableConfigPanel.tsx | 8 +- .../screen/panels/PropertiesPanel.tsx | 210 +++++- .../screen/panels/TemplatesPanel.tsx | 317 +++++++- frontend/lib/api/file.ts | 24 + frontend/lib/api/screen.ts | 75 +- frontend/next.config.mjs | 8 +- frontend/types/screen.ts | 77 +- 19 files changed, 2506 insertions(+), 167 deletions(-) diff --git a/backend-node/prisma/schema.prisma b/backend-node/prisma/schema.prisma index 5bcce352..6189aa8e 100644 --- a/backend-node/prisma/schema.prisma +++ b/backend-node/prisma/schema.prisma @@ -1478,22 +1478,23 @@ model material_release { } model menu_info { - objid Decimal @id @default(0) @db.Decimal - menu_type Decimal? @db.Decimal - parent_obj_id Decimal? @db.Decimal - menu_name_kor String? @db.VarChar(64) - menu_name_eng String? @db.VarChar(64) - seq Decimal? @db.Decimal - menu_url String? @db.VarChar(256) - menu_desc String? @db.VarChar(1024) - writer String? @db.VarChar(32) - regdate DateTime? @db.Timestamp(6) - status String? @db.VarChar(32) - system_name String? @db.VarChar(32) - company_code String? @default("*") @db.VarChar(50) - lang_key String? @db.VarChar(100) - lang_key_desc String? @db.VarChar(100) - company company_mng? @relation(fields: [company_code], references: [company_code]) + objid Decimal @id @default(0) @db.Decimal + menu_type Decimal? @db.Decimal + parent_obj_id Decimal? @db.Decimal + menu_name_kor String? @db.VarChar(64) + menu_name_eng String? @db.VarChar(64) + seq Decimal? @db.Decimal + menu_url String? @db.VarChar(256) + menu_desc String? @db.VarChar(1024) + writer String? @db.VarChar(32) + regdate DateTime? @db.Timestamp(6) + status String? @db.VarChar(32) + system_name String? @db.VarChar(32) + company_code String? @default("*") @db.VarChar(50) + lang_key String? @db.VarChar(100) + lang_key_desc String? @db.VarChar(100) + company company_mng? @relation(fields: [company_code], references: [company_code]) + screen_assignments screen_menu_assignments[] @@index([parent_obj_id]) @@index([company_code]) @@ -4989,20 +4990,25 @@ model zz_230410_user_info { model screen_definitions { screen_id Int @id @default(autoincrement()) 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) company_code String @db.VarChar(50) description String? - is_active String @default("Y") @db.Char(1) + is_active String @default("Y") @db.Char(1) // Y=활성, N=비활성, D=삭제됨(휴지통) layout_metadata Json? created_date DateTime @default(now()) @db.Timestamp(6) created_by String? @db.VarChar(50) updated_date DateTime @default(now()) @db.Timestamp(6) updated_by String? @db.VarChar(50) + deleted_date DateTime? @db.Timestamp(6) // 삭제 일시 (휴지통 이동 시점) + deleted_by String? @db.VarChar(50) // 삭제한 사용자 + delete_reason String? // 삭제 사유 (선택사항) layouts screen_layouts[] menu_assignments screen_menu_assignments[] @@index([company_code]) + @@index([is_active, company_code]) + @@index([deleted_date], map: "idx_screen_definitions_deleted") } model screen_layouts { @@ -5066,6 +5072,7 @@ model screen_menu_assignments { created_date DateTime @default(now()) @db.Timestamp(6) created_by String? @db.VarChar(50) 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]) @@index([company_code]) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ac92d38b..f84004e4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -4,6 +4,7 @@ import cors from "cors"; import helmet from "helmet"; import compression from "compression"; import rateLimit from "express-rate-limit"; +import path from "path"; import config from "./config/environment"; import { logger } from "./utils/logger"; import { errorHandler } from "./middleware/errorHandler"; @@ -29,6 +30,23 @@ app.use(compression()); app.use(express.json({ 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에서 이미 올바른 형태로 처리됨 app.use( cors({ diff --git a/backend-node/src/controllers/fileController.ts b/backend-node/src/controllers/fileController.ts index 98430546..c1d185b9 100644 --- a/backend-node/src/controllers/fileController.ts +++ b/backend-node/src/controllers/fileController.ts @@ -495,6 +495,125 @@ export const getFileList = async ( } }; +/** + * 파일 미리보기 (이미지 등) + */ +export const previewFile = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + 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: "파일 미리보기 중 오류가 발생했습니다.", + }); + } +}; + /** * 파일 다운로드 */ diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 73e22583..c3db9989 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -90,24 +90,180 @@ export const updateScreen = async ( } }; -// 화면 삭제 -export const deleteScreen = async ( +// 화면 의존성 체크 +export const checkScreenDependencies = async ( req: AuthenticatedRequest, res: Response ) => { try { const { id } = req.params; 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) { + 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); + + // 의존성 오류인 경우 특별 처리 + if (error.code === "SCREEN_HAS_DEPENDENCIES") { + res.status(409).json({ + success: false, + message: error.message, + code: error.code, + dependencies: error.dependencies, + }); + return; + } + res .status(500) .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 ( req: AuthenticatedRequest, @@ -349,3 +505,26 @@ export const unassignScreenFromMenu = async ( .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: "메뉴 할당 정리에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/fileRoutes.ts b/backend-node/src/routes/fileRoutes.ts index 0770b8b2..b7b4c975 100644 --- a/backend-node/src/routes/fileRoutes.ts +++ b/backend-node/src/routes/fileRoutes.ts @@ -4,6 +4,7 @@ import { deleteFile, getFileList, downloadFile, + previewFile, getLinkedFiles, uploadMiddleware, } from "../controllers/fileController"; @@ -43,6 +44,13 @@ router.get("/linked/:tableName/:recordId", getLinkedFiles); */ router.delete("/:objid", deleteFile); +/** + * @route GET /api/files/preview/:objid + * @desc 파일 미리보기 (이미지 등) + * @access Private + */ +router.get("/preview/:objid", previewFile); + /** * @route GET /api/files/download/:objid * @desc 파일 다운로드 diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 33fb8697..bc15c279 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -6,6 +6,11 @@ import { createScreen, updateScreen, deleteScreen, + checkScreenDependencies, + restoreScreen, + permanentDeleteScreen, + getDeletedScreens, + bulkPermanentDeleteScreens, copyScreen, getTables, getTableInfo, @@ -16,6 +21,7 @@ import { assignScreenToMenu, getScreensByMenu, unassignScreenFromMenu, + cleanupDeletedScreenMenuAssignments, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -28,9 +34,16 @@ router.get("/screens", getScreens); router.get("/screens/:id", getScreen); router.post("/screens", createScreen); 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.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); @@ -48,4 +61,10 @@ router.post("/screens/:screenId/assign-menu", assignScreenToMenu); router.get("/menus/:menuObjid/screens", getScreensByMenu); router.delete("/screens/:screenId/menus/:menuObjid", unassignScreenFromMenu); +// 관리자용 정리 기능 +router.post( + "/admin/cleanup-deleted-screen-menu-assignments", + cleanupDeletedScreenMenuAssignments +); + export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 76b5da12..9bfc5588 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -50,8 +50,11 @@ export class ScreenManagementService { console.log(`사용자 회사 코드:`, userCompanyCode); // 화면 코드 중복 확인 - const existingScreen = await prisma.screen_definitions.findUnique({ - where: { screen_code: screenData.screenCode }, + const existingScreen = await prisma.screen_definitions.findFirst({ + where: { + screen_code: screenData.screenCode, + is_active: { not: "D" }, // 삭제되지 않은 화면만 중복 검사 + }, }); console.log( @@ -79,15 +82,18 @@ export class ScreenManagementService { } /** - * 회사별 화면 목록 조회 (페이징 지원) + * 회사별 화면 목록 조회 (페이징 지원) - 활성 화면만 */ async getScreensByCompany( companyCode: string, page: number = 1, size: number = 20 ): Promise> { - const whereClause = - companyCode === "*" ? {} : { company_code: companyCode }; + const whereClause: any = { is_active: { not: "D" } }; // 삭제된 화면 제외 + + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } const [screens, total] = await Promise.all([ prisma.screen_definitions.findMany({ @@ -111,11 +117,14 @@ export class ScreenManagementService { } /** - * 화면 목록 조회 (간단 버전) + * 화면 목록 조회 (간단 버전) - 활성 화면만 */ async getScreens(companyCode: string): Promise { - const whereClause = - companyCode === "*" ? {} : { company_code: companyCode }; + const whereClause: any = { is_active: { not: "D" } }; // 삭제된 화면 제외 + + if (companyCode !== "*") { + whereClause.company_code = companyCode; + } const screens = await prisma.screen_definitions.findMany({ where: whereClause, @@ -126,31 +135,37 @@ export class ScreenManagementService { } /** - * 화면 정의 조회 + * 화면 정의 조회 (활성 화면만) */ async getScreenById(screenId: number): Promise { - const screen = await prisma.screen_definitions.findUnique({ - where: { screen_id: screenId }, + const screen = await prisma.screen_definitions.findFirst({ + where: { + screen_id: screenId, + is_active: { not: "D" }, // 삭제된 화면 제외 + }, }); return screen ? this.mapToScreenDefinition(screen) : null; } /** - * 화면 정의 조회 (회사 코드 검증 포함) + * 화면 정의 조회 (회사 코드 검증 포함, 활성 화면만) */ async getScreen( screenId: number, companyCode: string ): Promise { - const whereClause: any = { screen_id: screenId }; + const whereClause: any = { + screen_id: screenId, + is_active: { not: "D" }, // 삭제된 화면 제외 + }; // 회사 코드가 '*'가 아닌 경우 회사별 필터링 if (companyCode !== "*") { whereClause.company_code = companyCode; } - const screen = await prisma.screen_definitions.findUnique({ + const screen = await prisma.screen_definitions.findFirst({ where: whereClause, }); @@ -196,9 +211,237 @@ export class ScreenManagementService { } /** - * 화면 정의 삭제 + * 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인 */ - async deleteScreen(screenId: number, userCompanyCode: string): Promise { + 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 { // 권한 확인 const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, @@ -215,11 +458,315 @@ export class ScreenManagementService { 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 { + // 권한 확인 + 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 { + // 권한 확인 + 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({ 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, + }; + } + // ======================================== // 테이블 관리 // ======================================== diff --git a/frontend/README.md b/frontend/README.md index e215bc4c..77812bae 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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). +## 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 First, run the development server: diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 37f85957..73fc6e0e 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -42,7 +42,7 @@ import { tableTypeApi } from "@/lib/api/screen"; import { getCurrentUser, UserInfo } from "@/lib/api/client"; import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen"; 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 { FileUpload } from "@/components/screen/widgets/FileUpload"; @@ -111,6 +111,8 @@ export const InteractiveDataTable: React.FC = ({ const [showPreviewModal, setShowPreviewModal] = useState(false); const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); + const [imageLoadError, setImageLoadError] = useState(false); + const [alternativeImageUrl, setAlternativeImageUrl] = useState(null); // 파일 관리 상태 const [fileStatusMap, setFileStatusMap] = useState>({}); // 행별 파일 상태 @@ -224,6 +226,8 @@ export const InteractiveDataTable: React.FC = ({ setShowPreviewModal(true); setZoom(1); setRotation(0); + setImageLoadError(false); + setAlternativeImageUrl(null); }, []); const closePreviewModal = useCallback(() => { @@ -231,6 +235,8 @@ export const InteractiveDataTable: React.FC = ({ setPreviewImage(null); setZoom(1); setRotation(0); + setImageLoadError(false); + setAlternativeImageUrl(null); }, []); const handleZoom = useCallback((direction: "in" | "out") => { @@ -254,6 +260,25 @@ export const InteractiveDataTable: React.FC = ({ const i = Math.floor(Math.log(bytes) / Math.log(k)); 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 [currentFileData, setCurrentFileData] = useState(null); const [currentFileColumn, setCurrentFileColumn] = useState(null); @@ -2248,16 +2273,13 @@ export const InteractiveDataTable: React.FC = ({
{previewImage && ( {previewImage.name} { - console.error("이미지 로딩 실패:", previewImage); - toast.error("이미지를 불러올 수 없습니다."); - }} + onError={handleImageError} /> )}
diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index ee00bdef..ea001eaa 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -6,6 +6,8 @@ import { WebType, WidgetComponent, FileComponent, + AreaComponent, + AreaLayoutType, DateTypeConfig, NumberTypeConfig, SelectTypeConfig, @@ -50,6 +52,15 @@ import { Edit, Trash2, Upload, + Square, + CreditCard, + Layout, + Grid3x3, + Columns, + Rows, + SidebarOpen, + Folder, + ChevronUp, } from "lucide-react"; interface RealtimePreviewProps { @@ -62,6 +73,159 @@ interface RealtimePreviewProps { children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 } +// 영역 레이아웃에 따른 아이콘 반환 +const getAreaIcon = (layoutType: AreaLayoutType) => { + switch (layoutType) { + case "box": + return ; + case "card": + return ; + case "panel": + return ; + case "section": + return ; + case "grid": + return ; + case "flex-row": + return ; + case "flex-column": + return ; + case "sidebar": + return ; + case "header-content": + return ; + case "tabs": + return ; + case "accordion": + return ; + default: + return ; + } +}; + +// 영역 컴포넌트 렌더링 +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 ( +
+ {title} + {description && {description}} +
+ ); + }; + + // 컨텐츠 영역 스타일 + const contentStyle: React.CSSProperties = { + ...getLayoutStyle(), + flex: 1, + minHeight: 0, + }; + + // 자식 컴포넌트가 없을 때 표시할 플레이스홀더 + const renderPlaceholder = () => ( +
+
+ {getAreaIcon(layoutType)} +
+
{title || `${layoutType} 영역`}
+
{description || "컴포넌트를 이 영역에 드래그하세요"}
+
+
+
+ ); + + return ( +
+ {renderHeader()} +
{children && React.Children.count(children) > 0 ? children : renderPlaceholder()}
+
+ ); +}; + // 웹 타입에 따른 위젯 렌더링 const renderWidget = (component: ComponentData) => { // 위젯 컴포넌트가 아닌 경우 빈 div 반환 @@ -1193,6 +1357,17 @@ export const RealtimePreview: React.FC = ({ )} + {type === "area" && ( +
+ {renderArea(component as AreaComponent, children)} +
+ )} + {false && (() => { const dataTableComponent = component as any; // DataTableComponent 타입 diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 242a1ca2..7ea7f832 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1042,6 +1042,57 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ...templateComp.style, }, } 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 { // 위젯 컴포넌트 const widgetType = templateComp.widgetType || "text"; @@ -2715,8 +2766,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onDragStart={(e) => startComponentDrag(component, e)} onDragEnd={endDrag} > - {/* 컨테이너 및 그룹의 자식 컴포넌트들 렌더링 */} - {(component.type === "group" || component.type === "container") && + {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 */} + {(component.type === "group" || component.type === "container" || component.type === "area") && layout.components .filter((child) => child.parentId === component.id) .map((child) => { diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index df1993f3..22af1f8e 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -6,13 +6,27 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } 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 { screenApi } from "@/lib/api/screen"; import CreateScreenModal from "./CreateScreenModal"; @@ -24,8 +38,16 @@ interface ScreenListProps { onDesignScreen: (screen: ScreenDefinition) => void; } +type DeletedScreenDefinition = ScreenDefinition & { + deletedDate?: Date; + deletedBy?: string; + deleteReason?: string; +}; + export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) { + const [activeTab, setActiveTab] = useState("active"); const [screens, setScreens] = useState([]); + const [deletedScreens, setDeletedScreens] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [currentPage, setCurrentPage] = useState(1); @@ -34,20 +56,56 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [isCopyOpen, setIsCopyOpen] = useState(false); const [screenToCopy, setScreenToCopy] = useState(null); + // 삭제 관련 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [screenToDelete, setScreenToDelete] = useState(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(null); + + // 일괄삭제 관련 상태 + const [selectedScreenIds, setSelectedScreenIds] = useState([]); + const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); + const [bulkDeleting, setBulkDeleting] = useState(false); + // 화면 목록 로드 (실제 API) useEffect(() => { let abort = false; const load = async () => { try { setLoading(true); - const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); - if (abort) return; - // 응답 표준: { success, data, total } - setScreens(resp.data || []); - setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20))); + if (activeTab === "active") { + const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm }); + if (abort) return; + setScreens(resp.data || []); + 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) { console.error("화면 목록 조회 실패", e); - setScreens([]); + if (activeTab === "active") { + setScreens([]); + } else { + setDeletedScreens([]); + } setTotalPages(1); } finally { if (!abort) setLoading(false); @@ -57,7 +115,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr return () => { abort = true; }; - }, [currentPage, searchTerm]); + }, [currentPage, searchTerm, activeTab]); const filteredScreens = screens; // 서버 필터 기준 사용 @@ -84,10 +142,151 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr console.log("편집:", screen); }; - const handleDelete = (screen: ScreenDefinition) => { - if (confirm(`"${screen.screenName}" 화면을 삭제하시겠습니까?`)) { - // 삭제 API 호출 - console.log("삭제:", screen); + const handleDelete = async (screen: ScreenDefinition) => { + setScreenToDelete(screen); + setCheckingDependencies(true); + + 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,107 +325,232 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-80 pl-10" + disabled={activeTab === "trash"} /> - - {/* 화면 목록 테이블 */} - - - - 화면 목록 ({screens.length}) - - - - - - - 화면명 - 화면 코드 - 테이블명 - 상태 - 생성일 - 작업 - - - - {screens.map((screen) => ( - handleScreenSelect(screen)} - > - -
-
{screen.screenName}
- {screen.description &&
{screen.description}
} -
-
- - - {screen.screenCode} - - - - {screen.tableName} - - - - {screen.isActive === "Y" ? "활성" : "비활성"} - - - -
{screen.createdDate.toLocaleDateString()}
-
{screen.createdBy}
-
- - - - - - - onDesignScreen(screen)}> - - 화면 설계 - - handleView(screen)}> - - 미리보기 - - handleEdit(screen)}> - - 편집 - - handleCopy(screen)}> - - 복사 - - handleDelete(screen)} className="text-red-600"> - - 삭제 - - - - -
- ))} -
-
+ {/* 탭 구조 */} + + + 활성 화면 + 휴지통 + - {filteredScreens.length === 0 &&
검색 결과가 없습니다.
} -
-
+ {/* 활성 화면 탭 */} + + + + + 화면 목록 ({screens.length}) + + + + + + + 화면명 + 화면 코드 + 테이블명 + 상태 + 생성일 + 작업 + + + + {screens.map((screen) => ( + handleScreenSelect(screen)} + > + +
+
{screen.screenName}
+ {screen.description &&
{screen.description}
} +
+
+ + + {screen.screenCode} + + + + {screen.tableName} + + + + {screen.isActive === "Y" ? "활성" : "비활성"} + + + +
{screen.createdDate.toLocaleDateString()}
+
{screen.createdBy}
+
+ + + + + + + onDesignScreen(screen)}> + + 화면 설계 + + handleView(screen)}> + + 미리보기 + + handleEdit(screen)}> + + 편집 + + handleCopy(screen)}> + + 복사 + + handleDelete(screen)} + className="text-red-600" + disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId} + > + + {checkingDependencies && screenToDelete?.screenId === screen.screenId + ? "확인 중..." + : "삭제"} + + + + +
+ ))} +
+
+ + {filteredScreens.length === 0 && ( +
검색 결과가 없습니다.
+ )} +
+
+
+ + {/* 휴지통 탭 */} + + + + + 휴지통 ({deletedScreens.length}) + {selectedScreenIds.length > 0 && ( + + )} + + + + + + + + 0 && selectedScreenIds.length === deletedScreens.length} + onCheckedChange={handleSelectAll} + /> + + 화면명 + 화면 코드 + 테이블명 + 삭제일 + 삭제자 + 삭제 사유 + 작업 + + + + {deletedScreens.map((screen) => ( + + + handleScreenCheck(screen.screenId, checked as boolean)} + /> + + +
+
{screen.screenName}
+ {screen.description &&
{screen.description}
} +
+
+ + + {screen.screenCode} + + + + {screen.tableName} + + +
{screen.deletedDate?.toLocaleDateString()}
+
+ +
{screen.deletedBy}
+
+ +
+ {screen.deleteReason || "-"} +
+
+ +
+ + +
+
+
+ ))} +
+
+ + {deletedScreens.length === 0 && ( +
휴지통이 비어있습니다.
+ )} +
+
+
+ {/* 페이지네이션 */} {totalPages > 1 && ( @@ -269,6 +593,160 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr sourceScreen={screenToCopy} onCopySuccess={handleCopySuccess} /> + + {/* 삭제 확인 다이얼로그 */} + + + + 화면 삭제 확인 + + "{screenToDelete?.screenName}" 화면을 휴지통으로 이동하시겠습니까? +
+ 휴지통에서 언제든지 복원할 수 있습니다. +
+
+
+ +