From ca56cff114b07962394ef988d0b4f279f2d1c4a8 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 1 Sep 2025 14:00:31 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=83=80=EC=9E=85=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=97=B0=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/screenManagementController.ts | 575 ++----- .../src/routes/screenManagementRoutes.ts | 166 +- .../src/services/screenManagementService.ts | 336 +++- frontend/app/(main)/admin/screenMng/page.tsx | 250 +-- frontend/components/screen/ScreenDesigner.tsx | 1362 +++++++++-------- frontend/components/screen/ScreenList.tsx | 9 +- .../components/screen/TableTypeSelector.tsx | 20 +- .../components/screen/TemplateManager.tsx | 28 +- frontend/types/screen.ts | 18 +- 9 files changed, 1330 insertions(+), 1434 deletions(-) diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 5b2c96b0..4aa13bdb 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -1,455 +1,152 @@ -import { Request, Response } from "express"; -import { ScreenManagementService } from "../services/screenManagementService"; -import { - CreateScreenRequest, - UpdateScreenRequest, - SaveLayoutRequest, - MenuAssignmentRequest, - ColumnWebTypeSetting, - WebType, -} from "../types/screen"; -import { logger } from "../utils/logger"; +import { Response } from "express"; +import { screenManagementService } from "../services/screenManagementService"; +import { AuthenticatedRequest } from "../types/auth"; -export class ScreenManagementController { - private screenService: ScreenManagementService; - - constructor() { - this.screenService = new ScreenManagementService(); +// 화면 목록 조회 +export const getScreens = async (req: AuthenticatedRequest, res: Response) => { + try { + const { companyCode } = req.user as any; + const screens = await screenManagementService.getScreens(companyCode); + res.json({ success: true, data: screens }); + } catch (error) { + console.error("화면 목록 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "화면 목록 조회에 실패했습니다." }); } +}; - // ======================================== - // 화면 정의 관리 - // ======================================== - - /** - * 화면 목록 조회 (회사별) - */ - async getScreens(req: Request, res: Response): Promise { - try { - const { page = 1, size = 20 } = req.query; - const userCompanyCode = (req as any).user?.company_code || "*"; - - const result = await this.screenService.getScreensByCompany( - userCompanyCode, - Number(page), - Number(size) - ); - - res.json({ - success: true, - data: result.data, - pagination: result.pagination, - }); - } catch (error) { - logger.error("화면 목록 조회 실패:", error); - res.status(500).json({ - success: false, - message: "화면 목록을 조회하는 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } +// 화면 생성 +export const createScreen = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { companyCode } = req.user as any; + const screenData = { ...req.body, companyCode }; + const newScreen = await screenManagementService.createScreen( + screenData, + companyCode + ); + res.status(201).json({ success: true, data: newScreen }); + } catch (error) { + console.error("화면 생성 실패:", error); + res + .status(500) + .json({ success: false, message: "화면 생성에 실패했습니다." }); } +}; - /** - * 화면 생성 - */ - async createScreen(req: Request, res: Response): Promise { - try { - const screenData: CreateScreenRequest = req.body; - const userCompanyCode = (req as any).user?.company_code || "*"; - const userId = (req as any).user?.user_id || "system"; - - // 사용자 회사 코드 자동 설정 - if (userCompanyCode !== "*") { - screenData.companyCode = userCompanyCode; - } - screenData.createdBy = userId; - - const screen = await this.screenService.createScreen( - screenData, - userCompanyCode - ); - - res.status(201).json({ - success: true, - data: screen, - message: "화면이 성공적으로 생성되었습니다.", - }); - } catch (error) { - logger.error("화면 생성 실패:", error); - res.status(400).json({ - success: false, - message: - error instanceof Error ? error.message : "화면 생성에 실패했습니다.", - }); - } +// 화면 수정 +export const updateScreen = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { id } = req.params; + const { companyCode } = req.user as any; + const updateData = { ...req.body, companyCode }; + const updatedScreen = await screenManagementService.updateScreen( + parseInt(id), + updateData, + companyCode + ); + res.json({ success: true, data: updatedScreen }); + } catch (error) { + console.error("화면 수정 실패:", error); + res + .status(500) + .json({ success: false, message: "화면 수정에 실패했습니다." }); } +}; - /** - * 화면 조회 - */ - async getScreen(req: Request, res: Response): Promise { - try { - const { screenId } = req.params; - const screen = await this.screenService.getScreenById(Number(screenId)); - - if (!screen) { - res.status(404).json({ - success: false, - message: "화면을 찾을 수 없습니다.", - }); - return; - } - - res.json({ - success: true, - data: screen, - }); - } catch (error) { - logger.error("화면 조회 실패:", error); - res.status(500).json({ - success: false, - message: "화면을 조회하는 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } +// 화면 삭제 +export const deleteScreen = 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: "화면이 삭제되었습니다." }); + } catch (error) { + console.error("화면 삭제 실패:", error); + res + .status(500) + .json({ success: false, message: "화면 삭제에 실패했습니다." }); } +}; - /** - * 화면 수정 - */ - async updateScreen(req: Request, res: Response): Promise { - try { - const { screenId } = req.params; - const updateData: UpdateScreenRequest = req.body; - const userCompanyCode = (req as any).user?.company_code || "*"; - const userId = (req as any).user?.user_id || "system"; - - updateData.updatedBy = userId; - - const screen = await this.screenService.updateScreen( - Number(screenId), - updateData, - userCompanyCode - ); - - res.json({ - success: true, - data: screen, - message: "화면이 성공적으로 수정되었습니다.", - }); - } catch (error) { - logger.error("화면 수정 실패:", error); - res.status(400).json({ - success: false, - message: - error instanceof Error ? error.message : "화면 수정에 실패했습니다.", - }); - } +// 테이블 목록 조회 +export const getTables = async (req: AuthenticatedRequest, res: Response) => { + try { + const { companyCode } = req.user as any; + const tables = await screenManagementService.getTables(companyCode); + res.json({ success: true, data: tables }); + } catch (error) { + console.error("테이블 목록 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "테이블 목록 조회에 실패했습니다." }); } +}; - /** - * 화면 삭제 - */ - async deleteScreen(req: Request, res: Response): Promise { - try { - const { screenId } = req.params; - const userCompanyCode = (req as any).user?.company_code || "*"; - - await this.screenService.deleteScreen(Number(screenId), userCompanyCode); - - res.json({ - success: true, - message: "화면이 성공적으로 삭제되었습니다.", - }); - } catch (error) { - logger.error("화면 삭제 실패:", error); - res.status(400).json({ - success: false, - message: - error instanceof Error ? error.message : "화면 삭제에 실패했습니다.", - }); - } +// 테이블 컬럼 정보 조회 +export const getTableColumns = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { tableName } = req.params; + const { companyCode } = req.user as any; + const columns = await screenManagementService.getTableColumns( + tableName, + companyCode + ); + res.json({ success: true, data: columns }); + } catch (error) { + console.error("테이블 컬럼 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "테이블 컬럼 조회에 실패했습니다." }); } +}; - // ======================================== - // 레이아웃 관리 - // ======================================== - - /** - * 레이아웃 조회 - */ - async getLayout(req: Request, res: Response): Promise { - try { - const { screenId } = req.params; - const layout = await this.screenService.getLayout(Number(screenId)); - - if (!layout) { - res.json({ - success: true, - data: { - components: [], - gridSettings: { - columns: 12, - gap: 16, - padding: 16, - }, - }, - }); - return; - } - - res.json({ - success: true, - data: layout, - }); - } catch (error) { - logger.error("레이아웃 조회 실패:", error); - res.status(500).json({ - success: false, - message: "레이아웃을 조회하는 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } +// 레이아웃 저장 +export const saveLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const layoutData = req.body; + const savedLayout = await screenManagementService.saveLayout( + parseInt(screenId), + layoutData, + companyCode + ); + res.json({ success: true, data: savedLayout }); + } catch (error) { + console.error("레이아웃 저장 실패:", error); + res + .status(500) + .json({ success: false, message: "레이아웃 저장에 실패했습니다." }); } +}; - /** - * 레이아웃 저장 - */ - async saveLayout(req: Request, res: Response): Promise { - try { - const { screenId } = req.params; - const layoutData: SaveLayoutRequest = req.body; - - await this.screenService.saveLayout(Number(screenId), layoutData); - - res.json({ - success: true, - message: "레이아웃이 성공적으로 저장되었습니다.", - }); - } catch (error) { - logger.error("레이아웃 저장 실패:", error); - res.status(400).json({ - success: false, - message: - error instanceof Error - ? error.message - : "레이아웃 저장에 실패했습니다.", - }); - } +// 레이아웃 조회 +export const getLayout = async (req: AuthenticatedRequest, res: Response) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const layout = await screenManagementService.getLayout( + parseInt(screenId), + companyCode + ); + res.json({ success: true, data: layout }); + } catch (error) { + console.error("레이아웃 조회 실패:", error); + res + .status(500) + .json({ success: false, message: "레이아웃 조회에 실패했습니다." }); } - - // ======================================== - // 템플릿 관리 - // ======================================== - - /** - * 템플릿 목록 조회 - */ - async getTemplates(req: Request, res: Response): Promise { - try { - const { type, isPublic } = req.query; - const userCompanyCode = (req as any).user?.company_code || "*"; - - const templates = await this.screenService.getTemplatesByCompany( - userCompanyCode, - type as string, - isPublic === "true" - ); - - res.json({ - success: true, - data: templates, - }); - } catch (error) { - logger.error("템플릿 목록 조회 실패:", error); - res.status(500).json({ - success: false, - message: "템플릿 목록을 조회하는 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } - } - - /** - * 템플릿 생성 - */ - async createTemplate(req: Request, res: Response): Promise { - try { - const templateData = req.body; - const userCompanyCode = (req as any).user?.company_code || "*"; - const userId = (req as any).user?.user_id || "system"; - - templateData.company_code = userCompanyCode; - templateData.created_by = userId; - - const template = await this.screenService.createTemplate(templateData); - - res.status(201).json({ - success: true, - data: template, - message: "템플릿이 성공적으로 생성되었습니다.", - }); - } catch (error) { - logger.error("템플릿 생성 실패:", error); - res.status(400).json({ - success: false, - message: - error instanceof Error - ? error.message - : "템플릿 생성에 실패했습니다.", - }); - } - } - - // ======================================== - // 메뉴 할당 관리 - // ======================================== - - /** - * 화면-메뉴 할당 - */ - async assignScreenToMenu(req: Request, res: Response): Promise { - try { - const { screenId } = req.params; - const assignmentData: MenuAssignmentRequest = req.body; - const userCompanyCode = (req as any).user?.company_code || "*"; - const userId = (req as any).user?.user_id || "system"; - - // 사용자 회사 코드 자동 설정 - if (userCompanyCode !== "*") { - assignmentData.companyCode = userCompanyCode; - } - assignmentData.createdBy = userId; - - await this.screenService.assignScreenToMenu( - Number(screenId), - assignmentData - ); - - res.json({ - success: true, - message: "화면이 메뉴에 성공적으로 할당되었습니다.", - }); - } catch (error) { - logger.error("메뉴 할당 실패:", error); - res.status(400).json({ - success: false, - message: - error instanceof Error ? error.message : "메뉴 할당에 실패했습니다.", - }); - } - } - - /** - * 메뉴별 화면 목록 조회 - */ - async getScreensByMenu(req: Request, res: Response): Promise { - try { - const { menuObjid } = req.params; - const userCompanyCode = (req as any).user?.company_code || "*"; - - const screens = await this.screenService.getScreensByMenu( - Number(menuObjid), - userCompanyCode - ); - - res.json({ - success: true, - data: screens, - }); - } catch (error) { - logger.error("메뉴별 화면 목록 조회 실패:", error); - res.status(500).json({ - success: false, - message: "메뉴별 화면 목록을 조회하는 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } - } - - // ======================================== - // 테이블 타입 연계 - // ======================================== - - /** - * 테이블 컬럼 정보 조회 - */ - async getTableColumns(req: Request, res: Response): Promise { - try { - const { tableName } = req.params; - const columns = await this.screenService.getColumnInfo(tableName); - - res.json({ - success: true, - data: columns, - }); - } catch (error) { - logger.error("테이블 컬럼 정보 조회 실패:", error); - res.status(500).json({ - success: false, - message: "테이블 컬럼 정보를 조회하는 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } - } - - /** - * 컬럼 웹 타입 설정 - */ - async setColumnWebType(req: Request, res: Response): Promise { - try { - const { tableName, columnName } = req.params; - const { webType, ...additionalSettings } = req.body; - - await this.screenService.setColumnWebType( - tableName, - columnName, - webType as WebType, - additionalSettings - ); - - res.json({ - success: true, - message: "컬럼 웹 타입이 성공적으로 설정되었습니다.", - }); - } catch (error) { - logger.error("컬럼 웹 타입 설정 실패:", error); - res.status(400).json({ - success: false, - message: - error instanceof Error - ? error.message - : "컬럼 웹 타입 설정에 실패했습니다.", - }); - } - } - - /** - * 테이블 목록 조회 (화면 생성용) - */ - async getTables(req: Request, res: Response): Promise { - try { - // PostgreSQL에서 사용 가능한 테이블 목록 조회 - const tables = await (this.screenService as any).prisma.$queryRaw` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE' - ORDER BY table_name - `; - - res.json({ - success: true, - data: tables, - }); - } catch (error) { - logger.error("테이블 목록 조회 실패:", error); - res.status(500).json({ - success: false, - message: "테이블 목록을 조회하는 중 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }); - } - } -} +}; diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index e6d9655f..10459f7c 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -1,159 +1,33 @@ import express from "express"; -import { ScreenManagementController } from "../controllers/screenManagementController"; import { authenticateToken } from "../middleware/authMiddleware"; +import { + getScreens, + createScreen, + updateScreen, + deleteScreen, + getTables, + getTableColumns, + saveLayout, + getLayout, +} from "../controllers/screenManagementController"; const router = express.Router(); -const screenController = new ScreenManagementController(); // 모든 라우트에 인증 미들웨어 적용 router.use(authenticateToken); -// ======================================== -// 화면 정의 관리 -// ======================================== +// 화면 관리 +router.get("/screens", getScreens); +router.post("/screens", createScreen); +router.put("/screens/:id", updateScreen); +router.delete("/screens/:id", deleteScreen); -/** - * @route POST /screens - * @desc 새 화면 생성 - * @access Private - */ -router.post("/screens", screenController.createScreen.bind(screenController)); +// 테이블 관리 +router.get("/tables", getTables); +router.get("/tables/:tableName/columns", getTableColumns); -/** - * @route GET /screens - * @desc 회사별 화면 목록 조회 - * @access Private - */ -router.get("/screens", screenController.getScreens.bind(screenController)); - -/** - * @route GET /screens/:screenId - * @desc 특정 화면 조회 - * @access Private - */ -router.get( - "/screens/:screenId", - screenController.getScreen.bind(screenController) -); - -/** - * @route PUT /screens/:screenId - * @desc 화면 정보 수정 - * @access Private - */ -router.put( - "/screens/:screenId", - screenController.updateScreen.bind(screenController) -); - -/** - * @route DELETE /screens/:screenId - * @desc 화면 삭제 - * @access Private - */ -router.delete( - "/screens/:screenId", - screenController.deleteScreen.bind(screenController) -); - -// ======================================== // 레이아웃 관리 -// ======================================== - -/** - * @route GET /screens/:screenId/layout - * @desc 화면 레이아웃 조회 - * @access Private - */ -router.get( - "/screens/:screenId/layout", - screenController.getLayout.bind(screenController) -); - -/** - * @route POST /screens/:screenId/layout - * @desc 화면 레이아웃 저장 - * @access Private - */ -router.post( - "/screens/:screenId/layout", - screenController.saveLayout.bind(screenController) -); - -// ======================================== -// 템플릿 관리 -// ======================================== - -/** - * @route GET /templates - * @desc 회사별 템플릿 목록 조회 - * @access Private - */ -router.get("/templates", screenController.getTemplates.bind(screenController)); - -/** - * @route POST /templates - * @desc 새 템플릿 생성 - * @access Private - */ -router.post( - "/templates", - screenController.createTemplate.bind(screenController) -); - -// ======================================== -// 메뉴 할당 관리 -// ======================================== - -/** - * @route POST /screens/:screenId/menu-assignments - * @desc 화면을 메뉴에 할당 - * @access Private - */ -router.post( - "/screens/:screenId/menu-assignments", - screenController.assignScreenToMenu.bind(screenController) -); - -/** - * @route GET /menus/:menuObjid/screens - * @desc 메뉴별 할당된 화면 목록 조회 - * @access Private - */ -router.get( - "/menus/:menuObjid/screens", - screenController.getScreensByMenu.bind(screenController) -); - -// ======================================== -// 테이블 타입 연계 -// ======================================== - -/** - * @route GET /tables - * @desc 사용 가능한 테이블 목록 조회 - * @access Private - */ -router.get("/tables", screenController.getTables.bind(screenController)); - -/** - * @route GET /tables/:tableName/columns - * @desc 테이블의 컬럼 정보 조회 - * @access Private - */ -router.get( - "/tables/:tableName/columns", - screenController.getTableColumns.bind(screenController) -); - -/** - * @route PUT /tables/:tableName/columns/:columnName/web-type - * @desc 컬럼의 웹 타입 설정 - * @access Private - */ -router.put( - "/tables/:tableName/columns/:columnName/web-type", - screenController.setColumnWebType.bind(screenController) -); +router.post("/screens/:screenId/layout", saveLayout); +router.get("/screens/:screenId/layout", getLayout); export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index dac621eb..88e4b406 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -16,6 +16,13 @@ import { } from "../types/screen"; import { generateId } from "../utils/generateId"; +// 백엔드에서 사용할 테이블 정보 타입 +interface TableInfo { + tableName: string; + tableLabel: string; + columns: ColumnInfo[]; +} + export class ScreenManagementService { // ======================================== // 화면 정의 관리 @@ -83,6 +90,21 @@ export class ScreenManagementService { }; } + /** + * 화면 목록 조회 (간단 버전) + */ + async getScreens(companyCode: string): Promise { + const whereClause = + companyCode === "*" ? {} : { company_code: companyCode }; + + const screens = await prisma.screen_definitions.findMany({ + where: whereClause, + orderBy: { created_date: "desc" }, + }); + + return screens.map((screen) => this.mapToScreenDefinition(screen)); + } + /** * 화면 정의 조회 */ @@ -102,56 +124,225 @@ export class ScreenManagementService { updateData: UpdateScreenRequest, userCompanyCode: string ): Promise { - // 권한 검증 - const screen = await prisma.screen_definitions.findUnique({ + // 권한 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); - if (!screen) { + if (!existingScreen) { throw new Error("화면을 찾을 수 없습니다."); } - if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { - throw new Error("해당 화면을 수정할 권한이 없습니다."); + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + throw new Error("이 화면을 수정할 권한이 없습니다."); } - const updatedScreen = await prisma.screen_definitions.update({ + const screen = await prisma.screen_definitions.update({ where: { screen_id: screenId }, data: { screen_name: updateData.screenName, description: updateData.description, - is_active: updateData.isActive, + is_active: updateData.isActive ? "Y" : "N", updated_by: updateData.updatedBy, updated_date: new Date(), }, }); - return this.mapToScreenDefinition(updatedScreen); + return this.mapToScreenDefinition(screen); } /** * 화면 정의 삭제 */ async deleteScreen(screenId: number, userCompanyCode: string): Promise { - // 권한 검증 - const screen = await prisma.screen_definitions.findUnique({ + // 권한 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); - if (!screen) { + if (!existingScreen) { throw new Error("화면을 찾을 수 없습니다."); } - if (userCompanyCode !== "*" && userCompanyCode !== screen.company_code) { - throw new Error("해당 화면을 삭제할 권한이 없습니다."); + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + throw new Error("이 화면을 삭제할 권한이 없습니다."); } - // CASCADE로 인해 관련 레이아웃과 위젯도 자동 삭제됨 await prisma.screen_definitions.delete({ where: { screen_id: screenId }, }); } + // ======================================== + // 테이블 관리 + // ======================================== + + /** + * 테이블 목록 조회 + */ + async getTables(companyCode: string): Promise { + try { + // PostgreSQL에서 사용 가능한 테이블 목록 조회 + const tables = await prisma.$queryRaw>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `; + + // 각 테이블의 컬럼 정보도 함께 조회 + const tableInfos: TableInfo[] = []; + + for (const table of tables) { + const columns = await this.getTableColumns( + table.table_name, + companyCode + ); + if (columns.length > 0) { + tableInfos.push({ + tableName: table.table_name, + tableLabel: this.getTableLabel(table.table_name), + columns: columns, + }); + } + } + + return tableInfos; + } catch (error) { + console.error("테이블 목록 조회 실패:", error); + throw new Error("테이블 목록을 조회할 수 없습니다."); + } + } + + /** + * 테이블 컬럼 정보 조회 + */ + async getTableColumns( + tableName: string, + companyCode: string + ): Promise { + try { + // 테이블 컬럼 정보 조회 + const columns = await prisma.$queryRaw< + Array<{ + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; + character_maximum_length: number | null; + numeric_precision: number | null; + numeric_scale: number | null; + }> + >` + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = ${tableName} + ORDER BY ordinal_position + `; + + // column_labels 테이블에서 웹타입 정보 조회 (있는 경우) + const webTypeInfo = await prisma.column_labels.findMany({ + where: { table_name: tableName }, + select: { + column_name: true, + web_type: true, + column_label: true, + detail_settings: true, + }, + }); + + // 컬럼 정보 매핑 + return columns.map((column) => { + const webTypeData = webTypeInfo.find( + (wt) => wt.column_name === column.column_name + ); + + return { + tableName: tableName, + columnName: column.column_name, + columnLabel: + webTypeData?.column_label || + this.getColumnLabel(column.column_name), + dataType: column.data_type, + webType: + (webTypeData?.web_type as WebType) || + this.inferWebType(column.data_type), + isNullable: column.is_nullable, + columnDefault: column.column_default || undefined, + characterMaximumLength: column.character_maximum_length || undefined, + numericPrecision: column.numeric_precision || undefined, + numericScale: column.numeric_scale || undefined, + detailSettings: webTypeData?.detail_settings || undefined, + }; + }); + } catch (error) { + console.error("테이블 컬럼 조회 실패:", error); + throw new Error("테이블 컬럼 정보를 조회할 수 없습니다."); + } + } + + /** + * 테이블 라벨 생성 + */ + private getTableLabel(tableName: string): string { + // snake_case를 읽기 쉬운 형태로 변환 + return tableName + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + .replace(/\s+/g, " ") + .trim(); + } + + /** + * 컬럼 라벨 생성 + */ + private getColumnLabel(columnName: string): string { + // snake_case를 읽기 쉬운 형태로 변환 + return columnName + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + .replace(/\s+/g, " ") + .trim(); + } + + /** + * 데이터 타입으로부터 웹타입 추론 + */ + private inferWebType(dataType: string): WebType { + const lowerType = dataType.toLowerCase(); + + if (lowerType.includes("char") || lowerType.includes("text")) { + return "text"; + } else if ( + lowerType.includes("int") || + lowerType.includes("numeric") || + lowerType.includes("decimal") + ) { + return "number"; + } else if (lowerType.includes("date") || lowerType.includes("time")) { + return "date"; + } else if (lowerType.includes("bool")) { + return "checkbox"; + } else { + return "text"; + } + } + // ======================================== // 레이아웃 관리 // ======================================== @@ -161,109 +352,107 @@ export class ScreenManagementService { */ async saveLayout( screenId: number, - layoutData: SaveLayoutRequest + layoutData: LayoutData, + companyCode: string ): Promise { - // 화면 존재 확인 - const screen = await prisma.screen_definitions.findUnique({ + // 권한 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ where: { screen_id: screenId }, }); - if (!screen) { + if (!existingScreen) { throw new Error("화면을 찾을 수 없습니다."); } + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); + } + // 기존 레이아웃 삭제 await prisma.screen_layouts.deleteMany({ where: { screen_id: screenId }, }); // 새 레이아웃 저장 - const layoutPromises = layoutData.components.map((component) => - prisma.screen_layouts.create({ + for (const component of layoutData.components) { + const { id, ...componentData } = component; + + // Prisma JSON 필드에 맞는 타입으로 변환 + const properties: any = { + ...componentData, + position: { + x: component.position.x, + y: component.position.y, + }, + size: { + width: component.size.width, + height: component.size.height, + }, + }; + + await prisma.screen_layouts.create({ data: { screen_id: screenId, component_type: component.type, component_id: component.id, - parent_id: component.parentId, + parent_id: component.parentId || null, position_x: component.position.x, position_y: component.position.y, width: component.size.width, height: component.size.height, - properties: component.properties, - display_order: component.displayOrder || 0, + properties: properties, }, - }) - ); - - await Promise.all(layoutPromises); + }); + } } /** * 레이아웃 조회 */ - async getLayout(screenId: number): Promise { + async getLayout( + screenId: number, + companyCode: string + ): Promise { + // 권한 확인 + const existingScreen = await prisma.screen_definitions.findUnique({ + where: { screen_id: screenId }, + }); + + if (!existingScreen) { + return null; + } + + if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); + } + const layouts = await prisma.screen_layouts.findMany({ where: { screen_id: screenId }, orderBy: { display_order: "asc" }, }); if (layouts.length === 0) { - return null; + return { + components: [], + gridSettings: { columns: 12, gap: 16, padding: 16 }, + }; } const components: ComponentData[] = layouts.map((layout) => { - const baseComponent = { + const properties = layout.properties as any; + return { id: layout.component_id, type: layout.component_type as any, position: { x: layout.position_x, y: layout.position_y }, size: { width: layout.width, height: layout.height }, - properties: layout.properties as Record, - displayOrder: layout.display_order, + parentId: layout.parent_id, + ...properties, }; - - // 컴포넌트 타입별 추가 속성 처리 - switch (layout.component_type) { - case "group": - return { - ...baseComponent, - type: "group", - title: (layout.properties as any)?.title, - backgroundColor: (layout.properties as any)?.backgroundColor, - border: (layout.properties as any)?.border, - borderRadius: (layout.properties as any)?.borderRadius, - shadow: (layout.properties as any)?.shadow, - padding: (layout.properties as any)?.padding, - margin: (layout.properties as any)?.margin, - collapsible: (layout.properties as any)?.collapsible, - collapsed: (layout.properties as any)?.collapsed, - children: (layout.properties as any)?.children || [], - }; - case "widget": - return { - ...baseComponent, - type: "widget", - tableName: (layout.properties as any)?.tableName, - columnName: (layout.properties as any)?.columnName, - widgetType: (layout.properties as any)?.widgetType, - label: (layout.properties as any)?.label, - placeholder: (layout.properties as any)?.placeholder, - required: (layout.properties as any)?.required, - readonly: (layout.properties as any)?.readonly, - validationRules: (layout.properties as any)?.validationRules, - displayProperties: (layout.properties as any)?.displayProperties, - }; - default: - return baseComponent; - } }); return { components, - gridSettings: { - columns: 12, - gap: 16, - padding: 16, - }, + gridSettings: { columns: 12, gap: 16, padding: 16 }, }; } @@ -616,3 +805,6 @@ export class ScreenManagementService { }; } } + +// 서비스 인스턴스 export +export const screenManagementService = new ScreenManagementService(); diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/page.tsx index 27025dec..0b481e39 100644 --- a/frontend/app/(main)/admin/screenMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -1,122 +1,178 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Plus, Grid3X3, Palette, Settings, FileText } from "lucide-react"; +import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenDefinition } from "@/types/screen"; +// 단계별 진행을 위한 타입 정의 +type Step = "list" | "design" | "template"; + export default function ScreenManagementPage() { + const [currentStep, setCurrentStep] = useState("list"); const [selectedScreen, setSelectedScreen] = useState(null); - const [activeTab, setActiveTab] = useState("screens"); + const [stepHistory, setStepHistory] = useState(["list"]); + + // 단계별 제목과 설명 + const stepConfig = { + list: { + title: "화면 목록 관리", + description: "생성된 화면들을 확인하고 관리하세요", + icon: "📋", + }, + design: { + title: "화면 설계", + description: "드래그앤드롭으로 화면을 설계하세요", + icon: "🎨", + }, + template: { + title: "템플릿 관리", + description: "화면 템플릿을 관리하고 재사용하세요", + icon: "📝", + }, + }; + + // 다음 단계로 이동 + const goToNextStep = (nextStep: Step) => { + setStepHistory((prev) => [...prev, nextStep]); + setCurrentStep(nextStep); + }; + + // 이전 단계로 이동 + const goToPreviousStep = () => { + if (stepHistory.length > 1) { + const newHistory = stepHistory.slice(0, -1); + const previousStep = newHistory[newHistory.length - 1]; + setStepHistory(newHistory); + setCurrentStep(previousStep); + } + }; + + // 특정 단계로 이동 + const goToStep = (step: Step) => { + setCurrentStep(step); + // 해당 단계까지의 히스토리만 유지 + const stepIndex = stepHistory.findIndex((s) => s === step); + if (stepIndex !== -1) { + setStepHistory(stepHistory.slice(0, stepIndex + 1)); + } + }; + + // 단계별 진행 상태 확인 + const isStepCompleted = (step: Step) => { + return stepHistory.includes(step); + }; + + // 현재 단계가 마지막 단계인지 확인 + const isLastStep = currentStep === "template"; return ( -
+
{/* 페이지 헤더 */}

화면관리 시스템

-

드래그앤드롭으로 화면을 설계하고 관리하세요

+

단계별로 화면을 관리하고 설계하세요

- +
{stepConfig[currentStep].description}
- {/* 메인 컨텐츠 */} - - - - - 화면 관리 - - - - 화면 설계기 - - - - 템플릿 관리 - - - - 설정 - - - - {/* 화면 관리 탭 */} - - - - 화면 목록 - - - - - - - - {/* 화면 설계기 탭 */} - - - - 화면 설계기 - - - {selectedScreen ? ( - - ) : ( -
- -

설계할 화면을 선택해주세요

-

화면 관리 탭에서 화면을 선택한 후 설계기를 사용하세요

-
- )} -
-
-
- - {/* 템플릿 관리 탭 */} - - - - 템플릿 관리 - - - - - - - - {/* 설정 탭 */} - - - - 화면관리 시스템 설정 - - -
-
-

테이블 타입 연계

-

테이블 타입관리 시스템과의 연계 설정을 관리합니다.

-
-
-

권한 관리

-

회사별 화면 접근 권한을 설정합니다.

-
-
-

기본 설정

-

그리드 시스템, 기본 컴포넌트 등의 기본 설정을 관리합니다.

+ {/* 단계별 진행 표시 */} +
+
+ {Object.entries(stepConfig).map(([step, config], index) => ( +
+
+ +
+
+ {config.title} +
- - - - + {index < Object.keys(stepConfig).length - 1 && ( +
+ )} +
+ ))} +
+
+ + {/* 단계별 내용 */} +
+ {/* 화면 목록 단계 */} + {currentStep === "list" && ( +
+
+

{stepConfig.list.title}

+ +
+ { + setSelectedScreen(screen); + goToNextStep("design"); + }} + /> +
+ )} + + {/* 화면 설계 단계 */} + {currentStep === "design" && ( +
+ goToStep("list")} /> +
+ )} + + {/* 템플릿 관리 단계 */} + {currentStep === "template" && ( +
+
+

{stepConfig.template.title}

+
+ + +
+
+ goToStep("list")} /> +
+ )} +
); } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 9197096a..339d84c0 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -22,6 +22,10 @@ import { Ungroup, Database, Trash2, + Table, + Settings, + ChevronDown, + ChevronRight, } from "lucide-react"; import { ScreenDefinition, @@ -33,6 +37,7 @@ import { WebType, WidgetComponent, ColumnInfo, + TableInfo, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import ContainerComponent from "./layout/ContainerComponent"; @@ -45,9 +50,11 @@ import TemplateManager from "./TemplateManager"; import StyleEditor from "./StyleEditor"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; interface ScreenDesignerProps { - screen: ScreenDefinition; + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; } interface ComponentMoveState { @@ -57,7 +64,7 @@ interface ComponentMoveState { currentPosition: { x: number; y: number }; } -export default function ScreenDesigner({ screen }: ScreenDesignerProps) { +export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { const [layout, setLayout] = useState({ components: [], gridSettings: { columns: 12, gap: 16, padding: 16 }, @@ -83,112 +90,267 @@ export default function ScreenDesigner({ screen }: ScreenDesignerProps) { originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, }); + const [activeTab, setActiveTab] = useState("tables"); + const [tables, setTables] = useState([]); + const [expandedTables, setExpandedTables] = useState>(new Set()); - // 기본 컴포넌트 정의 - const basicComponents = [ - { type: "text", label: "텍스트 입력", color: "bg-blue-500", icon: "Type" }, - { type: "number", label: "숫자 입력", color: "bg-green-500", icon: "Hash" }, - { type: "date", label: "날짜 선택", color: "bg-purple-500", icon: "Calendar" }, - { type: "select", label: "선택 박스", color: "bg-orange-500", icon: "CheckSquare" }, - { type: "textarea", label: "텍스트 영역", color: "bg-indigo-500", icon: "FileText" }, - { type: "checkbox", label: "체크박스", color: "bg-pink-500", icon: "CheckSquare" }, - { type: "radio", label: "라디오 버튼", color: "bg-yellow-500", icon: "Radio" }, - { type: "file", label: "파일 업로드", color: "bg-red-500", icon: "FileText" }, - { type: "code", label: "코드 입력", color: "bg-gray-500", icon: "Hash" }, - { type: "entity", label: "엔티티 선택", color: "bg-teal-500", icon: "Database" }, - ]; + // 테이블 데이터 로드 (실제로는 API에서 가져와야 함) + useEffect(() => { + const fetchTables = async () => { + try { + const response = await fetch("http://localhost:8080/api/screen-management/tables", { + headers: { + Authorization: `Bearer ${localStorage.getItem("authToken")}`, + }, + }); - const layoutComponents = [ - { type: "container", label: "컨테이너", color: "bg-gray-500" }, - { type: "row", label: "행", color: "bg-yellow-500" }, - { type: "column", label: "열", color: "bg-red-500" }, - { type: "group", label: "그룹", color: "bg-teal-500" }, - ]; + if (response.ok) { + const data = await response.json(); + if (data.success) { + setTables(data.data); + } else { + console.error("테이블 조회 실패:", data.message); + // 임시 데이터로 폴백 + setTables(getMockTables()); + } + } else { + console.error("테이블 조회 실패:", response.status); + // 임시 데이터로 폴백 + setTables(getMockTables()); + } + } catch (error) { + console.error("테이블 조회 중 오류:", error); + // 임시 데이터로 폴백 + setTables(getMockTables()); + } + }; - // 드래그 시작 - const startDrag = useCallback((componentType: ComponentType, source: "toolbox" | "canvas") => { - let componentData: ComponentData; - - if (componentType === "widget") { - // 위젯 컴포넌트 생성 - componentData = { - id: generateComponentId(componentType), - type: "widget", - position: { x: 0, y: 0 }, - size: { width: 6, height: 50 }, - label: "새 위젯", - tableName: "", - columnName: "", - widgetType: "text", - required: false, - readonly: false, - }; - } else if (componentType === "container") { - // 컨테이너 컴포넌트 생성 - componentData = { - id: generateComponentId(componentType), - type: "container", - position: { x: 0, y: 0 }, - size: { width: 12, height: 100 }, - title: "새 컨테이너", - children: [], - }; - } else if (componentType === "row") { - // 행 컴포넌트 생성 - componentData = { - id: generateComponentId(componentType), - type: "row", - position: { x: 0, y: 0 }, - size: { width: 12, height: 100 }, - children: [], - }; - } else if (componentType === "column") { - // 열 컴포넌트 생성 - componentData = { - id: generateComponentId(componentType), - type: "column", - position: { x: 0, y: 0 }, - size: { width: 6, height: 100 }, - children: [], - }; - } else if (componentType === "group") { - // 그룹 컴포넌트 생성 - componentData = { - id: generateComponentId(componentType), - type: "group", - position: { x: 0, y: 0 }, - size: { width: 12, height: 200 }, - title: "새 그룹", - children: [], - }; - } else { - throw new Error(`지원하지 않는 컴포넌트 타입: ${componentType}`); - } - - setDragState((prev) => ({ - ...prev, - isDragging: true, - draggedComponent: componentData, - dragOffset: { x: 0, y: 0 }, - })); + fetchTables(); }, []); - // 드래그 종료 - const endDrag = useCallback(() => { - setDragState({ - isDragging: false, - draggedComponent: null, - dragOffset: { x: 0, y: 0 }, + // 임시 테이블 데이터 (API 실패 시 사용) + const getMockTables = (): TableInfo[] => [ + { + tableName: "user_info", + tableLabel: "사용자 정보", + columns: [ + { + tableName: "user_info", + columnName: "user_id", + columnLabel: "사용자 ID", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "user_info", + columnName: "user_name", + columnLabel: "사용자명", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "user_info", + columnName: "email", + columnLabel: "이메일", + webType: "email", + dataType: "VARCHAR", + isNullable: "YES", + }, + { + tableName: "user_info", + columnName: "phone", + columnLabel: "전화번호", + webType: "tel", + dataType: "VARCHAR", + isNullable: "YES", + }, + { + tableName: "user_info", + columnName: "birth_date", + columnLabel: "생년월일", + webType: "date", + dataType: "DATE", + isNullable: "YES", + }, + { + tableName: "user_info", + columnName: "is_active", + columnLabel: "활성화", + webType: "checkbox", + dataType: "BOOLEAN", + isNullable: "NO", + }, + ], + }, + { + tableName: "product_info", + tableLabel: "제품 정보", + columns: [ + { + tableName: "product_info", + columnName: "product_id", + columnLabel: "제품 ID", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "product_info", + columnName: "product_name", + columnLabel: "제품명", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "product_info", + columnName: "category", + columnLabel: "카테고리", + webType: "select", + dataType: "VARCHAR", + isNullable: "YES", + }, + { + tableName: "product_info", + columnName: "price", + columnLabel: "가격", + webType: "number", + dataType: "DECIMAL", + isNullable: "YES", + }, + { + tableName: "product_info", + columnName: "description", + columnLabel: "설명", + webType: "textarea", + dataType: "TEXT", + isNullable: "YES", + }, + { + tableName: "product_info", + columnName: "created_date", + columnLabel: "생성일", + webType: "date", + dataType: "TIMESTAMP", + isNullable: "NO", + }, + ], + }, + { + tableName: "order_info", + tableLabel: "주문 정보", + columns: [ + { + tableName: "order_info", + columnName: "order_id", + columnLabel: "주문 ID", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "customer_name", + columnLabel: "고객명", + webType: "text", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "order_date", + columnLabel: "주문일", + webType: "date", + dataType: "DATE", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "total_amount", + columnLabel: "총 금액", + webType: "number", + dataType: "DECIMAL", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "status", + columnLabel: "상태", + webType: "select", + dataType: "VARCHAR", + isNullable: "NO", + }, + { + tableName: "order_info", + columnName: "notes", + columnLabel: "비고", + webType: "textarea", + dataType: "TEXT", + isNullable: "YES", + }, + ], + }, + ]; + + // 테이블 확장/축소 토글 + const toggleTableExpansion = useCallback((tableName: string) => { + setExpandedTables((prev) => { + const newSet = new Set(prev); + if (newSet.has(tableName)) { + newSet.delete(tableName); + } else { + newSet.add(tableName); + } + return newSet; }); }, []); - // 컴포넌트 추가 - const addComponent = useCallback((component: ComponentData, position: { x: number; y: number }) => { - const newComponent = { - ...component, - id: generateComponentId(component.type), + // 웹타입에 따른 위젯 타입 매핑 + const getWidgetTypeFromWebType = useCallback((webType: string): string => { + switch (webType) { + case "text": + case "email": + case "tel": + return "text"; + case "number": + case "decimal": + return "number"; + case "date": + case "datetime": + return "date"; + case "select": + case "dropdown": + return "select"; + case "textarea": + case "text_area": + return "textarea"; + case "checkbox": + case "boolean": + return "checkbox"; + case "radio": + return "radio"; + default: + return "text"; + } + }, []); + + // 컴포넌트 추가 함수 + const addComponent = useCallback((componentData: Partial, position: { x: number; y: number }) => { + const newComponent: ComponentData = { + id: generateComponentId(), + type: "widget", position, - }; + size: { width: 6, height: 60 }, + tableName: "", + columnName: "", + widgetType: "text", + label: "", + required: false, + readonly: false, + ...componentData, + } as ComponentData; setLayout((prev) => ({ ...prev, @@ -196,644 +358,506 @@ export default function ScreenDesigner({ screen }: ScreenDesignerProps) { })); }, []); - // 컴포넌트 선택 - const selectComponent = useCallback((component: ComponentData) => { - setSelectedComponent(component); - }, []); + // 컴포넌트 제거 함수 + const removeComponent = useCallback( + (componentId: string) => { + setLayout((prev) => ({ + ...prev, + components: prev.components.filter((comp) => comp.id !== componentId), + })); + if (selectedComponent?.id === componentId) { + setSelectedComponent(null); + } + }, + [selectedComponent], + ); - // 컴포넌트 삭제 - const removeComponent = useCallback((componentId: string) => { + // 컴포넌트 속성 업데이트 함수 + const updateComponentProperty = useCallback((componentId: string, propertyPath: string, value: any) => { setLayout((prev) => ({ ...prev, - components: prev.components.filter((c) => c.id !== componentId), - })); - setSelectedComponent(null); - }, []); + components: prev.components.map((comp) => { + if (comp.id === componentId) { + const newComp = { ...comp }; + const pathParts = propertyPath.split("."); + let current: any = newComp; - // 컴포넌트 속성 업데이트 - const updateComponentProperty = useCallback((componentId: string, path: string, value: any) => { - setLayout((prev) => ({ - ...prev, - components: prev.components.map((c) => { - if (c.id === componentId) { - const newComponent = { ...c } as any; - const keys = path.split("."); - let current = newComponent; - for (let i = 0; i < keys.length - 1; i++) { - current = current[keys[i]]; + for (let i = 0; i < pathParts.length - 1; i++) { + current = current[pathParts[i]]; } - current[keys[keys.length - 1]] = value; - return newComponent; + current[pathParts[pathParts.length - 1]] = value; + + return newComp; } - return c; + return comp; }), })); }, []); - // 레이아웃 저장 - const saveLayout = useCallback(() => { - console.log("레이아웃 저장:", layout); - // TODO: API 호출로 레이아웃 저장 - }, [layout]); - - // 컴포넌트 재배치 시작 - const startComponentMove = useCallback((component: ComponentData, e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setMoveState({ - isMoving: true, - movingComponent: component, - originalPosition: { ...component.position }, - currentPosition: { ...component.position }, - }); + // 레이아웃 저장 함수 + const saveLayout = useCallback(async () => { + try { + // TODO: 실제 API 호출로 변경 + console.log("레이아웃 저장:", layout); + // await saveLayoutAPI(selectedScreen.screenId, layout); + } catch (error) { + console.error("레이아웃 저장 실패:", error); + } + }, [layout, selectedScreen]); + // 드래그 시작 + const startDrag = useCallback((componentData: Partial, e: React.DragEvent) => { + e.dataTransfer.setData("application/json", JSON.stringify(componentData)); setDragState((prev) => ({ ...prev, isDragging: true, - draggedComponent: component, - dragOffset: { x: 0, y: 0 }, + draggedComponent: componentData as ComponentData, })); }, []); - // 컴포넌트 재배치 중 - const handleComponentMove = useCallback( - (e: MouseEvent) => { - if (!moveState.isMoving || !moveState.movingComponent) return; - - const canvas = document.getElementById("design-canvas"); - if (!canvas) return; - - const rect = canvas.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / 50); - const y = Math.floor((e.clientY - rect.top) / 50); - - setMoveState((prev) => ({ - ...prev, - currentPosition: { x, y }, - })); - }, - [moveState.isMoving, moveState.movingComponent], - ); - - // 컴포넌트 재배치 완료 - const endComponentMove = useCallback(() => { - if (!moveState.isMoving || !moveState.movingComponent) return; - - const { movingComponent, currentPosition } = moveState; - - // 위치 업데이트 - setLayout((prev) => ({ - ...prev, - components: prev.components.map((c) => (c.id === movingComponent.id ? { ...c, position: currentPosition } : c)), - })); - - // 상태 초기화 - setMoveState({ - isMoving: false, - movingComponent: null, - originalPosition: { x: 0, y: 0 }, - currentPosition: { x: 0, y: 0 }, - }); - + // 드래그 종료 + const endDrag = useCallback(() => { setDragState((prev) => ({ ...prev, isDragging: false, draggedComponent: null, - dragOffset: { x: 0, y: 0 }, })); - }, [moveState]); + }, []); - // 마우스 이벤트 리스너 등록/해제 - useEffect(() => { - if (moveState.isMoving) { - document.addEventListener("mousemove", handleComponentMove); - document.addEventListener("mouseup", endComponentMove); + // 드롭 처리 + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const componentData = JSON.parse(e.dataTransfer.getData("application/json")); - return () => { - document.removeEventListener("mousemove", handleComponentMove); - document.removeEventListener("mouseup", endComponentMove); - }; - } - }, [moveState.isMoving, handleComponentMove, endComponentMove]); + // 드롭 위치 계산 (그리드 기반) + const rect = e.currentTarget.getBoundingClientRect(); + const x = Math.floor((e.clientX - rect.left) / 80); // 80px = 1 그리드 컬럼 + const y = Math.floor((e.clientY - rect.top) / 60); // 60px = 1 그리드 행 - // 컴포넌트 렌더링 - const renderComponent = useCallback( - (component: ComponentData) => { - const isSelected = selectedComponent === component; - const isMoving = moveState.isMoving && moveState.movingComponent?.id === component.id; - const currentPosition = isMoving ? moveState.currentPosition : component.position; - - switch (component.type) { - case "container": - return ( - selectComponent(component)} - onMouseDown={(e) => startComponentMove(component, e)} - isMoving={isMoving} - > - {/* 컨테이너 내부의 자식 컴포넌트들 */} - {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} - - ); - case "row": - return ( - selectComponent(component)} - onMouseDown={(e) => startComponentMove(component, e)} - isMoving={isMoving} - > - {/* 행 내부의 자식 컴포넌트들 */} - {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} - - ); - case "column": - return ( - selectComponent(component)} - onMouseDown={(e) => startComponentMove(component, e)} - isMoving={isMoving} - > - {/* 열 내부의 자식 컴포넌트들 */} - {layout.components.filter((c) => c.parentId === component.id).map(renderComponent)} - - ); - case "widget": - return ( -
selectComponent(component)} - onMouseDown={(e) => startComponentMove(component, e)} - > - -
- ); - default: - return null; - } + addComponent(componentData, { x, y }); + endDrag(); }, - [selectedComponent, moveState, selectComponent, startComponentMove, layout.components], + [addComponent, endDrag], ); - // 테이블 타입에서 컬럼 선택 시 위젯 생성 - const handleColumnSelect = useCallback( - (column: ColumnInfo) => { - const widgetComponent: WidgetComponent = { - id: generateComponentId("widget"), - type: "widget", - position: { x: 0, y: 0 }, - size: { width: 6, height: 50 }, - parentId: undefined, - tableName: column.tableName, - columnName: column.columnName, - widgetType: column.webType || "text", - label: column.columnLabel || column.columnName, - placeholder: `${column.columnLabel || column.columnName}을(를) 입력하세요`, - required: column.isNullable === "NO", - readonly: false, - validationRules: [], - displayProperties: {}, - style: { - // 웹 타입별 기본 스타일 - ...(column.webType === "date" && { - backgroundColor: "#fef3c7", - border: "1px solid #f59e0b", - }), - ...(column.webType === "number" && { - backgroundColor: "#dbeafe", - border: "1px solid #3b82f6", - }), - ...(column.webType === "select" && { - backgroundColor: "#f3e8ff", - border: "1px solid #8b5cf6", - }), - ...(column.webType === "checkbox" && { - backgroundColor: "#dcfce7", - border: "1px solid #22c55e", - }), - ...(column.webType === "radio" && { - backgroundColor: "#fef3c7", - border: "1px solid #f59e0b", - }), - ...(column.webType === "textarea" && { - backgroundColor: "#f1f5f9", - border: "1px solid #64748b", - }), - ...(column.webType === "file" && { - backgroundColor: "#fef2f2", - border: "1px solid #ef4444", - }), - ...(column.webType === "code" && { - backgroundColor: "#fef2f2", - border: "1px solid #ef4444", - fontFamily: "monospace", - }), - ...(column.webType === "entity" && { - backgroundColor: "#f0f9ff", - border: "1px solid #0ea5e9", - }), - }, - }; + // 드래그 오버 처리 + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); - // 현재 캔버스의 빈 위치 찾기 - const occupiedPositions = new Set(); - layout.components.forEach((comp) => { - for (let x = comp.position.x; x < comp.position.x + comp.size.width; x++) { - for (let y = comp.position.y; y < comp.position.y + comp.size.height; y++) { - occupiedPositions.add(`${x},${y}`); - } - } - }); - - // 빈 위치 찾기 - let newX = 0, - newY = 0; - for (let y = 0; y < 20; y++) { - for (let x = 0; x < 12; x++) { - let canPlace = true; - for (let dx = 0; dx < widgetComponent.size.width; dx++) { - for (let dy = 0; dy < Math.ceil(widgetComponent.size.height / 50); dy++) { - if (occupiedPositions.has(`${x + dx},${y + dy}`)) { - canPlace = false; - break; - } - } - if (!canPlace) break; - } - if (canPlace) { - newX = x; - newY = y; - break; - } - } - if (newX !== 0 || newY !== 0) break; - } - - widgetComponent.position = { x: newX, y: newY }; - addComponent(widgetComponent, { x: newX, y: newY }); - }, - [layout.components, addComponent], - ); + // 화면이 선택되지 않았을 때 처리 + if (!selectedScreen) { + return ( +
+
+ +

설계할 화면을 선택해주세요

+

화면 목록에서 화면을 선택한 후 설계기를 사용하세요

+ +
+
+ ); + } return ( -
- {/* 왼쪽 툴바 */} -
- {/* 기본 컴포넌트 */} - - - 기본 컴포넌트 - - - {basicComponents.map((component) => ( -
startDrag(component.type as ComponentType, "toolbox")} - onDragEnd={endDrag} - > -
- {component.label} -
- ))} - - - - {/* 레이아웃 컴포넌트 */} - - - 레이아웃 컴포넌트 - - - {layoutComponents.map((component) => ( -
startDrag(component.type as ComponentType, "toolbox")} - onDragEnd={endDrag} - > -
- {component.label} -
- ))} - - +
+ {/* 상단 헤더 */} +
+
+

{selectedScreen.screenName} - 화면 설계

+ + {selectedScreen.tableName} + +
+
+ + + +
- {/* 중앙 메인 영역 */} -
- - - - - 화면 설계 - - - - 테이블 타입 - - - - 미리보기 - - + {/* 메인 컨텐츠 영역 */} +
+ {/* 좌측: 테이블 타입 관리 */} +
+
+

테이블 타입

+

테이블과 컬럼을 드래그하여 캔버스에 배치하세요.

+
- {/* 화면 설계 탭 */} - - - -
- {screen.screenName} - 캔버스 +
+ {tables.map((table) => ( +
+ {/* 테이블 헤더 */} +
- - - + + {table.tableLabel} +
+ +
+ + {/* 테이블 드래그 가능 */} +
+ startDrag( + { + type: "container", + tableName: table.tableName, + label: table.tableLabel, + size: { width: 12, height: 80 }, + }, + e, + ) + } + onDragEnd={endDrag} + > +
+ + 테이블 전체 + + {table.columns.length} 컬럼 + - - -
{ - e.preventDefault(); - if (dragState.draggedComponent && dragState.draggedComponent.type === "widget") { - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / 50); - const y = Math.floor((e.clientY - rect.top) / 50); - // 위젯 컴포넌트의 경우 기본 컴포넌트에서 타입 정보를 가져옴 - const basicComponent = basicComponents.find( - (c) => c.type === (dragState.draggedComponent as any).widgetType, - ); - if (basicComponent) { - const widgetComponent: ComponentData = { - ...dragState.draggedComponent, - position: { x, y }, - label: basicComponent.label, - widgetType: basicComponent.type as WebType, - } as WidgetComponent; - addComponent(widgetComponent, { x, y }); - } - } else if (dragState.draggedComponent) { - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / 50); - const y = Math.floor((e.clientY - rect.top) / 50); - addComponent(dragState.draggedComponent, { x, y }); - } - }} - onDragOver={(e) => e.preventDefault()} - > + {/* 컬럼 목록 */} + {expandedTables.has(table.tableName) && ( +
+ {table.columns.map((column) => ( +
+ startDrag( + { + type: "widget", + tableName: table.tableName, + columnName: column.columnName, + widgetType: getWidgetTypeFromWebType(column.webType || "text"), + label: column.columnLabel || column.columnName, + size: { width: 6, height: 60 }, + }, + e, + ) + } + onDragEnd={endDrag} + > +
+
+ {column.webType === "text" && } + {column.webType === "number" && } + {column.webType === "date" && } + {column.webType === "select" && } + {column.webType === "textarea" && } + {column.webType === "checkbox" && } + {column.webType === "radio" && } + {!["text", "number", "date", "select", "textarea", "checkbox", "radio"].includes( + column.webType, + ) && } +
+
+
{column.columnLabel}
+
{column.columnName}
+
+ + {column.webType} + +
+
+ ))} +
+ )} +
+ ))} + + + + {/* 중앙: 캔버스 영역 */} +
+
+
+ {layout.components.length === 0 ? ( +
+
+ +

빈 캔버스

+

좌측에서 테이블이나 컬럼을 드래그하여 배치하세요

+
+
+ ) : ( +
{/* 그리드 가이드 */}
-
+
{Array.from({ length: 12 }).map((_, i) => (
))}
- {/* 컴포넌트들 렌더링 */} - {layout.components.length > 0 ? ( - layout.components - .filter((c) => !c.parentId) // 최상위 컴포넌트만 렌더링 - .map(renderComponent) - ) : ( -
-
- -

왼쪽 툴바에서 컴포넌트를 드래그하여 배치하세요

+ {/* 컴포넌트들 */} + {layout.components.map((component) => ( +
setSelectedComponent(component)} + > +
+ {component.type === "container" && ( + <> + +
+
{component.label}
+
{component.tableName}
+
+ + )} + {component.type === "widget" && ( + <> +
+ {component.widgetType === "text" && } + {component.widgetType === "number" && } + {component.widgetType === "date" && } + {component.widgetType === "select" && } + {component.widgetType === "textarea" && } + {component.widgetType === "checkbox" && } + {component.widgetType === "radio" && } +
+
+
{component.label}
+
{component.columnName}
+
+ + )}
- )} + ))}
- - - + )} +
+
+
- {/* 테이블 타입 탭 */} - - console.log("테이블 선택:", tableName)} - onColumnSelect={handleColumnSelect} - className="h-full" - /> - + {/* 우측: 컴포넌트 스타일 편집 */} +
+
+

컴포넌트 속성

- {/* 미리보기 탭 */} - - - - -
- - {/* 오른쪽 속성 패널 */} -
- - - 속성 - - {selectedComponent ? ( - - - 일반 - - - 스타일 - - 고급 - - - {/* 일반 속성 탭 */} - -
- - -
-
- - -
-
-
- - - updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value)) - } - /> -
-
- - - updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value)) - } - /> -
-
-
-
- - - updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value)) - } - /> -
-
- - - updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value)) - } - /> -
-
- - {/* 위젯 전용 속성 */} - {selectedComponent.type === "widget" && ( - <> - +
+ + + + {selectedComponent.type === "container" && "테이블 속성"} + {selectedComponent.type === "widget" && "위젯 속성"} + + + + {/* 위치 속성 */} +
- + updateComponentProperty(selectedComponent.id, "label", e.target.value)} + id="positionX" + type="number" + min="0" + value={selectedComponent.position.x} + onChange={(e) => + updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value)) + } />
- + updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)} + id="positionY" + type="number" + min="0" + value={selectedComponent.position.y} + onChange={(e) => + updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value)) + } />
-
-
- - updateComponentProperty(selectedComponent.id, "required", e.target.checked) - } - /> - -
-
- - updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) - } - /> - -
+
+ + {/* 크기 속성 */} +
+
+ + + updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value)) + } + />
- - )} - +
+ + + updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value)) + } + /> +
+
- {/* 스타일 속성 탭 */} - - updateComponentProperty(selectedComponent.id, "style", newStyle)} - /> - + {/* 테이블 정보 */} + +
+ + +
- {/* 고급 속성 탭 */} - -
- - -
- -
- + {/* 위젯 전용 속성 */} + {selectedComponent.type === "widget" && ( + <> +
+ + +
+
+ + +
+
+ + updateComponentProperty(selectedComponent.id, "label", e.target.value)} + /> +
+
+ + + updateComponentProperty(selectedComponent.id, "placeholder", e.target.value) + } + /> +
+
+
+ + updateComponentProperty(selectedComponent.id, "required", e.target.checked) + } + /> + +
+
+ + updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) + } + /> + +
+
+ + )} + + {/* 스타일 속성 */} + +
+ + updateComponentProperty(selectedComponent.id, "style", newStyle)} + /> +
+ + {/* 고급 속성 */} + +
+ + +
+ + + +
) : (
- +

컴포넌트를 선택하여 속성을 편집하세요

)} -
-
+
+
); diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 02aedb7c..c2999cee 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -12,15 +12,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search } from "lucide-react"; +import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; interface ScreenListProps { onScreenSelect: (screen: ScreenDefinition) => void; selectedScreen: ScreenDefinition | null; + onDesignScreen: (screen: ScreenDefinition) => void; } -export default function ScreenList({ onScreenSelect, selectedScreen }: ScreenListProps) { +export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) { const [screens, setScreens] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); @@ -198,6 +199,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen }: ScreenLis + onDesignScreen(screen)}> + + 화면 설계 + handleView(screen)}> 미리보기 diff --git a/frontend/components/screen/TableTypeSelector.tsx b/frontend/components/screen/TableTypeSelector.tsx index c16a45de..67557248 100644 --- a/frontend/components/screen/TableTypeSelector.tsx +++ b/frontend/components/screen/TableTypeSelector.tsx @@ -13,12 +13,18 @@ import { tableTypeApi } from "@/lib/api/screen"; import { useAuth } from "@/hooks/useAuth"; interface TableTypeSelectorProps { - onTableSelect?: (tableName: string) => void; - onColumnSelect?: (column: ColumnInfo) => void; + selectedTable?: string; + onTableChange?: (tableName: string) => void; + onColumnWebTypeChange?: (columnInfo: ColumnInfo) => void; className?: string; } -export default function TableTypeSelector({ onTableSelect, onColumnSelect, className }: TableTypeSelectorProps) { +export default function TableTypeSelector({ + selectedTable: propSelectedTable, + onTableChange, + onColumnWebTypeChange, + className, +}: TableTypeSelectorProps) { const { user } = useAuth(); const [tables, setTables] = useState< Array<{ tableName: string; displayName: string; description: string; columnCount: string }> @@ -140,12 +146,16 @@ export default function TableTypeSelector({ onTableSelect, onColumnSelect, class // 테이블 선택 const handleTableSelect = (tableName: string) => { setSelectedTable(tableName); - onTableSelect?.(tableName); + if (onTableChange) { + onTableChange(tableName); + } }; // 컬럼 선택 const handleColumnSelect = (column: ColumnInfo) => { - onColumnSelect?.(column); + if (onColumnWebTypeChange) { + onColumnWebTypeChange(column); + } }; // 웹 타입 변경 diff --git a/frontend/components/screen/TemplateManager.tsx b/frontend/components/screen/TemplateManager.tsx index b30739a6..4d759137 100644 --- a/frontend/components/screen/TemplateManager.tsx +++ b/frontend/components/screen/TemplateManager.tsx @@ -7,18 +7,26 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; -import { Search, Plus, Download, Upload, Trash2, Eye, Edit } from "lucide-react"; -import { ScreenTemplate, LayoutData } from "@/types/screen"; +import { Search, Plus, Download, Upload, Trash2, Eye, Edit, FileText } from "lucide-react"; +import { ScreenTemplate, LayoutData, ScreenDefinition } from "@/types/screen"; import { templateApi } from "@/lib/api/screen"; import { useAuth } from "@/hooks/useAuth"; interface TemplateManagerProps { + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; onTemplateSelect?: (template: ScreenTemplate) => void; onTemplateApply?: (template: ScreenTemplate) => void; className?: string; } -export default function TemplateManager({ onTemplateSelect, onTemplateApply, className }: TemplateManagerProps) { +export default function TemplateManager({ + selectedScreen, + onBackToList, + onTemplateSelect, + onTemplateApply, + className, +}: TemplateManagerProps) { const { user } = useAuth(); const [templates, setTemplates] = useState([]); const [selectedTemplate, setSelectedTemplate] = useState(null); @@ -211,6 +219,20 @@ export default function TemplateManager({ onTemplateSelect, onTemplateApply, cla URL.revokeObjectURL(url); }; + // 화면이 선택되지 않았을 때 처리 + if (!selectedScreen) { + return ( +
+ +

템플릿을 적용할 화면을 선택해주세요

+

화면 목록에서 화면을 선택한 후 템플릿을 관리하세요

+ +
+ ); + } + return (
{/* 헤더 */} diff --git a/frontend/types/screen.ts b/frontend/types/screen.ts index cd2ccf69..73d0bdcf 100644 --- a/frontend/types/screen.ts +++ b/frontend/types/screen.ts @@ -14,7 +14,14 @@ export type WebType = | "select" | "checkbox" | "radio" - | "file"; + | "file" + | "email" + | "tel" + | "datetime" + | "dropdown" + | "text_area" + | "boolean" + | "decimal"; // 위치 정보 export interface Position { @@ -28,6 +35,13 @@ export interface Size { height: number; // 픽셀 } +// 테이블 정보 +export interface TableInfo { + tableName: string; + tableLabel: string; + columns: ColumnInfo[]; +} + // 스타일 관련 타입 export interface ComponentStyle { // 레이아웃 @@ -109,6 +123,8 @@ export interface BaseComponent { size: { width: number; height: number }; parentId?: string; style?: ComponentStyle; // 스타일 속성 추가 + tableName?: string; // 테이블명 추가 + label?: string; // 라벨 추가 } // 컨테이너 컴포넌트