From 7caf2dea9442c6f8a85482f5e0709800b5e67ba4 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 5 Jan 2026 10:05:31 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 그룹 CRUD API 및 라우트 구현 - 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가 - 화면-그룹 연결 및 데이터 흐름 관리 기능 포함 - 프론트엔드에서 화면 그룹 필터링 및 시각화 기능 --- backend-node/src/app.ts | 2 + .../src/controllers/screenGroupController.ts | 983 ++++++++++++++++++ backend-node/src/routes/screenGroupRoutes.ts | 87 ++ .../admin/screenMng/screenMngList/page.tsx | 213 ++-- .../components/screen/ScreenGroupTreeView.tsx | 232 +++++ frontend/components/screen/ScreenList.tsx | 52 +- frontend/components/screen/ScreenNode.tsx | 446 ++++++++ .../components/screen/ScreenRelationFlow.tsx | 367 +++++++ .../components/screen/ScreenRelationView.tsx | 296 ++++++ .../screen/panels/DataFlowPanel.tsx | 457 ++++++++ .../screen/panels/FieldJoinPanel.tsx | 409 ++++++++ frontend/lib/api/screenGroup.ts | 387 +++++++ 12 files changed, 3853 insertions(+), 78 deletions(-) create mode 100644 backend-node/src/controllers/screenGroupController.ts create mode 100644 backend-node/src/routes/screenGroupRoutes.ts create mode 100644 frontend/components/screen/ScreenGroupTreeView.tsx create mode 100644 frontend/components/screen/ScreenNode.tsx create mode 100644 frontend/components/screen/ScreenRelationFlow.tsx create mode 100644 frontend/components/screen/ScreenRelationView.tsx create mode 100644 frontend/components/screen/panels/DataFlowPanel.tsx create mode 100644 frontend/components/screen/panels/FieldJoinPanel.tsx create mode 100644 frontend/lib/api/screenGroup.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index e928f96c..97564672 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -72,6 +72,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 +import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 @@ -196,6 +197,7 @@ app.use("/api/multilang", multilangRoutes); app.use("/api/table-management", tableManagementRoutes); app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/screen-management", screenManagementRoutes); +app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리 app.use("/api/common-codes", commonCodeRoutes); app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/files", fileRoutes); diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts new file mode 100644 index 00000000..d6f355c4 --- /dev/null +++ b/backend-node/src/controllers/screenGroupController.ts @@ -0,0 +1,983 @@ +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// pool 인스턴스 가져오기 +const pool = getPool(); + +// ============================================================ +// 화면 그룹 (screen_groups) CRUD +// ============================================================ + +// 화면 그룹 목록 조회 +export const getScreenGroups = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { page = 1, size = 20, searchTerm } = req.query; + const offset = (parseInt(page as string) - 1) * parseInt(size as string); + + let whereClause = "WHERE 1=1"; + const params: any[] = []; + let paramIndex = 1; + + // 회사 코드 필터링 (멀티테넌시) + if (companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + // 검색어 필터링 + if (searchTerm) { + whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${searchTerm}%`); + paramIndex++; + } + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as total FROM screen_groups ${whereClause}`; + const countResult = await pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // 데이터 조회 + const dataQuery = ` + SELECT + sg.*, + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count + FROM screen_groups sg + ${whereClause} + ORDER BY sg.display_order ASC, sg.created_date DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + params.push(parseInt(size as string), offset); + + const result = await pool.query(dataQuery, params); + + logger.info("화면 그룹 목록 조회", { companyCode, total, count: result.rows.length }); + + res.json({ + success: true, + data: result.rows, + total, + page: parseInt(page as string), + size: parseInt(size as string), + totalPages: Math.ceil(total / parseInt(size as string)), + }); + } catch (error: any) { + logger.error("화면 그룹 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 상세 조회 +export const getScreenGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = ` + SELECT sg.*, + (SELECT json_agg( + json_build_object( + 'id', sgs.id, + 'screen_id', sgs.screen_id, + 'screen_name', sd.screen_name, + 'screen_role', sgs.screen_role, + 'display_order', sgs.display_order, + 'is_default', sgs.is_default + ) ORDER BY sgs.display_order + ) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id + ) as screens + FROM screen_groups sg + WHERE sg.id = $1 + `; + const params: any[] = [id]; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND sg.company_code = $2`; + params.push(companyCode); + } + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." }); + } + + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("화면 그룹 상세 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 생성 +export const createScreenGroup = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { group_name, group_code, main_table_name, description, icon, display_order, is_active } = req.body; + + if (!group_name || !group_code) { + return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); + } + + const query = ` + INSERT INTO screen_groups (group_name, group_code, main_table_name, description, icon, display_order, is_active, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `; + const params = [ + group_name, + group_code, + main_table_name || null, + description || null, + icon || null, + display_order || 0, + is_active || 'Y', + companyCode === "*" ? "*" : companyCode, + userId + ]; + + const result = await pool.query(query, params); + + logger.info("화면 그룹 생성", { companyCode, groupId: result.rows[0].id, groupName: group_name }); + + res.json({ success: true, data: result.rows[0], message: "화면 그룹이 생성되었습니다." }); + } catch (error: any) { + logger.error("화면 그룹 생성 실패:", error); + if (error.code === '23505') { + return res.status(400).json({ success: false, message: "이미 존재하는 그룹 코드입니다." }); + } + res.status(500).json({ success: false, message: "화면 그룹 생성에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 수정 +export const updateScreenGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { group_name, group_code, main_table_name, description, icon, display_order, is_active } = req.body; + + let query = ` + UPDATE screen_groups + SET group_name = $1, group_code = $2, main_table_name = $3, description = $4, + icon = $5, display_order = $6, is_active = $7, updated_date = NOW() + WHERE id = $8 + `; + const params: any[] = [group_name, group_code, main_table_name, description, icon, display_order, is_active, id]; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND company_code = $9`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); + } + + logger.info("화면 그룹 수정", { companyCode, groupId: id }); + + res.json({ success: true, data: result.rows[0], message: "화면 그룹이 수정되었습니다." }); + } catch (error: any) { + logger.error("화면 그룹 수정 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 수정에 실패했습니다.", error: error.message }); + } +}; + +// 화면 그룹 삭제 +export const deleteScreenGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_groups WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); + } + + logger.info("화면 그룹 삭제", { companyCode, groupId: id }); + + res.json({ success: true, message: "화면 그룹이 삭제되었습니다." }); + } catch (error: any) { + logger.error("화면 그룹 삭제 실패:", error); + res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 화면-그룹 연결 (screen_group_screens) CRUD +// ============================================================ + +// 그룹에 화면 추가 +export const addScreenToGroup = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { group_id, screen_id, screen_role, display_order, is_default } = req.body; + + if (!group_id || !screen_id) { + return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." }); + } + + const query = ` + INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + const params = [ + group_id, + screen_id, + screen_role || 'main', + display_order || 0, + is_default || 'N', + companyCode === "*" ? "*" : companyCode, + userId + ]; + + const result = await pool.query(query, params); + + logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id }); + + res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." }); + } catch (error: any) { + logger.error("화면-그룹 연결 추가 실패:", error); + if (error.code === '23505') { + return res.status(400).json({ success: false, message: "이미 그룹에 추가된 화면입니다." }); + } + res.status(500).json({ success: false, message: "화면 추가에 실패했습니다.", error: error.message }); + } +}; + +// 그룹에서 화면 제거 +export const removeScreenFromGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_group_screens WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." }); + } + + logger.info("화면-그룹 연결 제거", { companyCode, id }); + + res.json({ success: true, message: "화면이 그룹에서 제거되었습니다." }); + } catch (error: any) { + logger.error("화면-그룹 연결 제거 실패:", error); + res.status(500).json({ success: false, message: "화면 제거에 실패했습니다.", error: error.message }); + } +}; + +// 그룹 내 화면 순서/역할 수정 +export const updateScreenInGroup = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { screen_role, display_order, is_default } = req.body; + + let query = ` + UPDATE screen_group_screens + SET screen_role = $1, display_order = $2, is_default = $3, updated_date = NOW() + WHERE id = $4 + `; + const params: any[] = [screen_role, display_order, is_default, id]; + + if (companyCode !== "*") { + query += ` AND company_code = $5`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "연결을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "화면 정보가 수정되었습니다." }); + } catch (error: any) { + logger.error("화면-그룹 연결 수정 실패:", error); + res.status(500).json({ success: false, message: "화면 정보 수정에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 화면 필드 조인 설정 (screen_field_joins) CRUD +// ============================================================ + +// 화면 필드 조인 목록 조회 +export const getFieldJoins = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { screen_id } = req.query; + + let query = ` + SELECT sfj.*, + tl1.table_label as save_table_label, + tl2.table_label as join_table_label + FROM screen_field_joins sfj + LEFT JOIN table_labels tl1 ON sfj.save_table = tl1.table_name + LEFT JOIN table_labels tl2 ON sfj.join_table = tl2.table_name + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + query += ` AND sfj.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + if (screen_id) { + query += ` AND sfj.screen_id = $${paramIndex}`; + params.push(screen_id); + paramIndex++; + } + + query += " ORDER BY sfj.id ASC"; + + const result = await pool.query(query, params); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("필드 조인 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면 필드 조인 생성 +export const createFieldJoin = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { + screen_id, layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active + } = req.body; + + if (!screen_id || !save_table || !save_column || !join_table || !join_column || !display_column) { + return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다." }); + } + + const query = ` + INSERT INTO screen_field_joins ( + screen_id, layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active, company_code, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING * + `; + const params = [ + screen_id, layout_id || null, component_id || null, field_name || null, + save_table, save_column, join_table, join_column, display_column, + join_type || 'LEFT', filter_condition || null, sort_column || null, sort_direction || 'ASC', + is_active || 'Y', companyCode === "*" ? "*" : companyCode, userId + ]; + + const result = await pool.query(query, params); + + logger.info("필드 조인 생성", { companyCode, screenId: screen_id, id: result.rows[0].id }); + + res.json({ success: true, data: result.rows[0], message: "필드 조인이 생성되었습니다." }); + } catch (error: any) { + logger.error("필드 조인 생성 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 생성에 실패했습니다.", error: error.message }); + } +}; + +// 화면 필드 조인 수정 +export const updateFieldJoin = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { + layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active + } = req.body; + + let query = ` + UPDATE screen_field_joins SET + layout_id = $1, component_id = $2, field_name = $3, + save_table = $4, save_column = $5, join_table = $6, join_column = $7, display_column = $8, + join_type = $9, filter_condition = $10, sort_column = $11, sort_direction = $12, + is_active = $13, updated_date = NOW() + WHERE id = $14 + `; + const params: any[] = [ + layout_id, component_id, field_name, + save_table, save_column, join_table, join_column, display_column, + join_type, filter_condition, sort_column, sort_direction, is_active, id + ]; + + if (companyCode !== "*") { + query += ` AND company_code = $15`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "필드 조인이 수정되었습니다." }); + } catch (error: any) { + logger.error("필드 조인 수정 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 수정에 실패했습니다.", error: error.message }); + } +}; + +// 화면 필드 조인 삭제 +export const deleteFieldJoin = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_field_joins WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "필드 조인을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, message: "필드 조인이 삭제되었습니다." }); + } catch (error: any) { + logger.error("필드 조인 삭제 실패:", error); + res.status(500).json({ success: false, message: "필드 조인 삭제에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 데이터 흐름 (screen_data_flows) CRUD +// ============================================================ + +// 데이터 흐름 목록 조회 +export const getDataFlows = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { group_id } = req.query; + + let query = ` + SELECT sdf.*, + sd1.screen_name as source_screen_name, + sd2.screen_name as target_screen_name, + sg.group_name + FROM screen_data_flows sdf + LEFT JOIN screen_definitions sd1 ON sdf.source_screen_id = sd1.screen_id + LEFT JOIN screen_definitions sd2 ON sdf.target_screen_id = sd2.screen_id + LEFT JOIN screen_groups sg ON sdf.group_id = sg.id + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + query += ` AND sdf.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + if (group_id) { + query += ` AND sdf.group_id = $${paramIndex}`; + params.push(group_id); + paramIndex++; + } + + query += " ORDER BY sdf.id ASC"; + + const result = await pool.query(query, params); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("데이터 흐름 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 데이터 흐름 생성 +export const createDataFlow = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping, flow_type, flow_label, condition_expression, is_active + } = req.body; + + if (!source_screen_id || !target_screen_id) { + return res.status(400).json({ success: false, message: "소스 화면과 타겟 화면은 필수입니다." }); + } + + const query = ` + INSERT INTO screen_data_flows ( + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping, flow_type, flow_label, condition_expression, is_active, company_code, writer + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING * + `; + const params = [ + group_id || null, source_screen_id, source_action || null, target_screen_id, target_action || null, + data_mapping ? JSON.stringify(data_mapping) : null, flow_type || 'unidirectional', + flow_label || null, condition_expression || null, is_active || 'Y', + companyCode === "*" ? "*" : companyCode, userId + ]; + + const result = await pool.query(query, params); + + logger.info("데이터 흐름 생성", { companyCode, id: result.rows[0].id }); + + res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 생성되었습니다." }); + } catch (error: any) { + logger.error("데이터 흐름 생성 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 생성에 실패했습니다.", error: error.message }); + } +}; + +// 데이터 흐름 수정 +export const updateDataFlow = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping, flow_type, flow_label, condition_expression, is_active + } = req.body; + + let query = ` + UPDATE screen_data_flows SET + group_id = $1, source_screen_id = $2, source_action = $3, + target_screen_id = $4, target_action = $5, data_mapping = $6, + flow_type = $7, flow_label = $8, condition_expression = $9, + is_active = $10, updated_date = NOW() + WHERE id = $11 + `; + const params: any[] = [ + group_id, source_screen_id, source_action, target_screen_id, target_action, + data_mapping ? JSON.stringify(data_mapping) : null, flow_type, flow_label, condition_expression, is_active, id + ]; + + if (companyCode !== "*") { + query += ` AND company_code = $12`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "데이터 흐름이 수정되었습니다." }); + } catch (error: any) { + logger.error("데이터 흐름 수정 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 수정에 실패했습니다.", error: error.message }); + } +}; + +// 데이터 흐름 삭제 +export const deleteDataFlow = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_data_flows WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "데이터 흐름을 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, message: "데이터 흐름이 삭제되었습니다." }); + } catch (error: any) { + logger.error("데이터 흐름 삭제 실패:", error); + res.status(500).json({ success: false, message: "데이터 흐름 삭제에 실패했습니다.", error: error.message }); + } +}; + + +// ============================================================ +// 화면-테이블 관계 (screen_table_relations) CRUD +// ============================================================ + +// 화면-테이블 관계 목록 조회 +export const getTableRelations = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const { screen_id, group_id } = req.query; + + let query = ` + SELECT str.*, + sd.screen_name, + sg.group_name, + tl.table_label + FROM screen_table_relations str + LEFT JOIN screen_definitions sd ON str.screen_id = sd.screen_id + LEFT JOIN screen_groups sg ON str.group_id = sg.id + LEFT JOIN table_labels tl ON str.table_name = tl.table_name + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*") { + query += ` AND str.company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + + if (screen_id) { + query += ` AND str.screen_id = $${paramIndex}`; + params.push(screen_id); + paramIndex++; + } + + if (group_id) { + query += ` AND str.group_id = $${paramIndex}`; + params.push(group_id); + paramIndex++; + } + + query += " ORDER BY str.id ASC"; + + const result = await pool.query(query, params); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("화면-테이블 관계 목록 조회 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 목록 조회에 실패했습니다.", error: error.message }); + } +}; + +// 화면-테이블 관계 생성 +export const createTableRelation = async (req: Request, res: Response) => { + try { + const companyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body; + + if (!screen_id || !table_name) { + return res.status(400).json({ success: false, message: "화면 ID와 테이블명은 필수입니다." }); + } + + const query = ` + INSERT INTO screen_table_relations (group_id, screen_id, table_name, relation_type, crud_operations, description, is_active, company_code, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `; + const params = [ + group_id || null, screen_id, table_name, relation_type || 'main', + crud_operations || 'CRUD', description || null, is_active || 'Y', + companyCode === "*" ? "*" : companyCode, userId + ]; + + const result = await pool.query(query, params); + + logger.info("화면-테이블 관계 생성", { companyCode, screenId: screen_id, tableName: table_name }); + + res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 생성되었습니다." }); + } catch (error: any) { + logger.error("화면-테이블 관계 생성 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 생성에 실패했습니다.", error: error.message }); + } +}; + +// 화면-테이블 관계 수정 +export const updateTableRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; + + let query = ` + UPDATE screen_table_relations SET + group_id = $1, table_name = $2, relation_type = $3, crud_operations = $4, + description = $5, is_active = $6, updated_date = NOW() + WHERE id = $7 + `; + const params: any[] = [group_id, table_name, relation_type, crud_operations, description, is_active, id]; + + if (companyCode !== "*") { + query += ` AND company_code = $8`; + params.push(companyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, data: result.rows[0], message: "화면-테이블 관계가 수정되었습니다." }); + } catch (error: any) { + logger.error("화면-테이블 관계 수정 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 수정에 실패했습니다.", error: error.message }); + } +}; + +// 화면-테이블 관계 삭제 +export const deleteTableRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = (req.user as any).companyCode; + + let query = `DELETE FROM screen_table_relations WHERE id = $1`; + const params: any[] = [id]; + + if (companyCode !== "*") { + query += ` AND company_code = $2`; + params.push(companyCode); + } + + query += " RETURNING id"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면-테이블 관계를 찾을 수 없거나 권한이 없습니다." }); + } + + res.json({ success: true, message: "화면-테이블 관계가 삭제되었습니다." }); + } catch (error: any) { + logger.error("화면-테이블 관계 삭제 실패:", error); + res.status(500).json({ success: false, message: "화면-테이블 관계 삭제에 실패했습니다.", error: error.message }); + } +}; + +// ============================================================ +// 화면 레이아웃 요약 정보 (미리보기용) +// ============================================================ + +// 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록) +export const getScreenLayoutSummary = async (req: Request, res: Response) => { + try { + const { screenId } = req.params; + + // 화면의 컴포넌트 정보 조회 + const query = ` + SELECT + properties->>'widgetType' as widget_type, + properties->>'label' as label, + properties->>'fieldName' as field_name, + properties->>'tableName' as table_name + FROM screen_layouts + WHERE screen_id = $1 + AND component_type = 'component' + ORDER BY display_order ASC + `; + + const result = await pool.query(query, [screenId]); + + // 위젯 타입별 집계 + const widgetCounts: Record = {}; + const labels: string[] = []; + const fields: Array<{ label: string; widgetType: string; fieldName?: string }> = []; + + result.rows.forEach((row: any) => { + const widgetType = row.widget_type || 'text'; + widgetCounts[widgetType] = (widgetCounts[widgetType] || 0) + 1; + + if (row.label && row.label !== '기본 버튼') { + labels.push(row.label); + fields.push({ + label: row.label, + widgetType: widgetType, + fieldName: row.field_name, + }); + } + }); + + // 화면 타입 추론 (가장 많은 컴포넌트 기준) + let screenType = 'form'; // 기본값 + if (widgetCounts['table'] > 0) { + screenType = 'grid'; + } else if (widgetCounts['custom'] > 2) { + screenType = 'dashboard'; + } else if (Object.keys(widgetCounts).length <= 2 && widgetCounts['button'] > 0) { + screenType = 'action'; + } + + logger.info("화면 레이아웃 요약 조회", { screenId, widgetCounts, fieldCount: fields.length }); + + res.json({ + success: true, + data: { + screenId: parseInt(screenId), + screenType, + widgetCounts, + totalComponents: result.rows.length, + fields: fields.slice(0, 10), // 최대 10개 + labels: labels.slice(0, 8), // 최대 8개 + }, + }); + } catch (error: any) { + logger.error("화면 레이아웃 요약 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 레이아웃 요약 조회에 실패했습니다.", error: error.message }); + } +}; + +// 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함) +export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => { + try { + const { screenIds } = req.body; + + if (!screenIds || !Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ success: false, message: "screenIds 배열이 필요합니다." }); + } + + // 여러 화면의 컴포넌트 정보 (좌표 포함) 한번에 조회 + // componentType이 더 정확한 위젯 종류 (table-list, button-primary 등) + const query = ` + SELECT + screen_id, + component_type, + position_x, + position_y, + width, + height, + properties->>'componentType' as component_kind, + properties->>'widgetType' as widget_type, + properties->>'label' as label + FROM screen_layouts + WHERE screen_id = ANY($1) + AND component_type = 'component' + ORDER BY screen_id, display_order ASC + `; + + const result = await pool.query(query, [screenIds]); + + // 화면별로 그룹핑 + const summaryMap: Record = {}; + + screenIds.forEach((id: number) => { + summaryMap[id] = { + screenId: id, + screenType: 'form', + widgetCounts: {}, + totalComponents: 0, + // 미니어처 렌더링용 레이아웃 데이터 + layoutItems: [], + canvasWidth: 0, + canvasHeight: 0, + }; + }); + + result.rows.forEach((row: any) => { + const screenId = row.screen_id; + // componentKind가 더 정확한 타입 (table-list, button-primary, table-search-widget 등) + const componentKind = row.component_kind || row.widget_type || 'text'; + const widgetType = row.widget_type || 'text'; + + if (summaryMap[screenId]) { + summaryMap[screenId].widgetCounts[componentKind] = + (summaryMap[screenId].widgetCounts[componentKind] || 0) + 1; + summaryMap[screenId].totalComponents++; + + // 레이아웃 아이템 추가 (미니어처 렌더링용) + summaryMap[screenId].layoutItems.push({ + x: row.position_x || 0, + y: row.position_y || 0, + width: row.width || 100, + height: row.height || 30, + componentKind: componentKind, // 정확한 컴포넌트 종류 + widgetType: widgetType, + label: row.label, + }); + + // 캔버스 크기 계산 (최대 좌표 기준) + const rightEdge = (row.position_x || 0) + (row.width || 100); + const bottomEdge = (row.position_y || 0) + (row.height || 30); + if (rightEdge > summaryMap[screenId].canvasWidth) { + summaryMap[screenId].canvasWidth = rightEdge; + } + if (bottomEdge > summaryMap[screenId].canvasHeight) { + summaryMap[screenId].canvasHeight = bottomEdge; + } + } + }); + + // 화면 타입 추론 (componentKind 기준) + Object.values(summaryMap).forEach((summary: any) => { + if (summary.widgetCounts['table-list'] > 0) { + summary.screenType = 'grid'; + } else if (summary.widgetCounts['table-search-widget'] > 1) { + summary.screenType = 'dashboard'; + } else if (summary.totalComponents <= 5 && summary.widgetCounts['button-primary'] > 0) { + summary.screenType = 'action'; + } + }); + + logger.info("여러 화면 레이아웃 요약 조회", { screenIds, count: Object.keys(summaryMap).length }); + + res.json({ + success: true, + data: summaryMap, + }); + } catch (error: any) { + logger.error("여러 화면 레이아웃 요약 조회 실패:", error); + res.status(500).json({ success: false, message: "여러 화면 레이아웃 요약 조회에 실패했습니다.", error: error.message }); + } +}; + diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts new file mode 100644 index 00000000..1f5fde05 --- /dev/null +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -0,0 +1,87 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + // 화면 그룹 + getScreenGroups, + getScreenGroup, + createScreenGroup, + updateScreenGroup, + deleteScreenGroup, + // 화면-그룹 연결 + addScreenToGroup, + removeScreenFromGroup, + updateScreenInGroup, + // 필드 조인 + getFieldJoins, + createFieldJoin, + updateFieldJoin, + deleteFieldJoin, + // 데이터 흐름 + getDataFlows, + createDataFlow, + updateDataFlow, + deleteDataFlow, + // 화면-테이블 관계 + getTableRelations, + createTableRelation, + updateTableRelation, + deleteTableRelation, + // 화면 레이아웃 요약 + getScreenLayoutSummary, + getMultipleScreenLayoutSummary, +} from "../controllers/screenGroupController"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// ============================================================ +// 화면 그룹 (screen_groups) +// ============================================================ +router.get("/groups", getScreenGroups); +router.get("/groups/:id", getScreenGroup); +router.post("/groups", createScreenGroup); +router.put("/groups/:id", updateScreenGroup); +router.delete("/groups/:id", deleteScreenGroup); + +// ============================================================ +// 화면-그룹 연결 (screen_group_screens) +// ============================================================ +router.post("/group-screens", addScreenToGroup); +router.put("/group-screens/:id", updateScreenInGroup); +router.delete("/group-screens/:id", removeScreenFromGroup); + +// ============================================================ +// 필드 조인 설정 (screen_field_joins) +// ============================================================ +router.get("/field-joins", getFieldJoins); +router.post("/field-joins", createFieldJoin); +router.put("/field-joins/:id", updateFieldJoin); +router.delete("/field-joins/:id", deleteFieldJoin); + +// ============================================================ +// 데이터 흐름 (screen_data_flows) +// ============================================================ +router.get("/data-flows", getDataFlows); +router.post("/data-flows", createDataFlow); +router.put("/data-flows/:id", updateDataFlow); +router.delete("/data-flows/:id", deleteDataFlow); + +// ============================================================ +// 화면-테이블 관계 (screen_table_relations) +// ============================================================ +router.get("/table-relations", getTableRelations); +router.post("/table-relations", createTableRelation); +router.put("/table-relations/:id", updateTableRelation); +router.delete("/table-relations/:id", deleteTableRelation); + +// ============================================================ +// 화면 레이아웃 요약 (미리보기용) +// ============================================================ +router.get("/layout-summary/:screenId", getScreenLayoutSummary); +router.post("/layout-summary/batch", getMultipleScreenLayoutSummary); + +export default router; + + diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 3145d9d3..512af3e8 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -1,68 +1,90 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react"; import ScreenList from "@/components/screen/ScreenList"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; +import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; +import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScreenDefinition } from "@/types/screen"; +import { screenApi } from "@/lib/api/screen"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import CreateScreenModal from "@/components/screen/CreateScreenModal"; // 단계별 진행을 위한 타입 정의 type Step = "list" | "design" | "template"; +type ViewMode = "tree" | "table"; export default function ScreenManagementPage() { const [currentStep, setCurrentStep] = useState("list"); const [selectedScreen, setSelectedScreen] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); + const [viewMode, setViewMode] = useState("tree"); + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [isCreateOpen, setIsCreateOpen] = useState(false); + + // 화면 목록 로드 + const loadScreens = useCallback(async () => { + try { + setLoading(true); + const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }); + // screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환 + if (result.data && result.data.length > 0) { + setScreens(result.data); + } + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadScreens(); + }, [loadScreens]); // 화면 설계 모드일 때는 전체 화면 사용 const isDesignMode = currentStep === "design"; - // 단계별 제목과 설명 - const stepConfig = { - list: { - title: "화면 목록 관리", - description: "생성된 화면들을 확인하고 관리하세요", - }, - design: { - title: "화면 설계", - description: "드래그앤드롭으로 화면을 설계하세요", - }, - template: { - title: "템플릿 관리", - description: "화면 템플릿을 관리하고 재사용하세요", - }, - }; - // 다음 단계로 이동 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 handleScreenSelect = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + }; + + // 화면 디자인 핸들러 + const handleDesignScreen = (screen: ScreenDefinition) => { + setSelectedScreen(screen); + goToNextStep("design"); + }; + + // 검색어로 필터링된 화면 + const filteredScreens = screens.filter((screen) => + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 if (isDesignMode) { return (
@@ -72,56 +94,93 @@ export default function ScreenManagementPage() { } return ( -
-
- {/* 페이지 헤더 */} -
-

화면 관리

-

화면을 설계하고 템플릿을 관리합니다

-
- - {/* 단계별 내용 */} -
- {/* 화면 목록 단계 */} - {currentStep === "list" && ( - { - setSelectedScreen(screen); - goToNextStep("design"); - }} - /> - )} - - {/* 템플릿 관리 단계 */} - {currentStep === "template" && ( -
-
-

{stepConfig.template.title}

-
- - -
-
- goToStep("list")} /> -
- )} +
+ {/* 페이지 헤더 */} +
+
+
+

화면 관리

+

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

+
+
+ {/* 뷰 모드 전환 */} + setViewMode(v as ViewMode)}> + + + + 트리 + + + + 테이블 + + + + + +
+ {/* 메인 콘텐츠 */} + {viewMode === "tree" ? ( +
+ {/* 왼쪽: 트리 구조 */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9" + /> +
+
+ {/* 트리 뷰 */} +
+ +
+
+ + {/* 오른쪽: 관계 시각화 (React Flow) */} +
+ +
+
+ ) : ( + // 테이블 뷰 (기존 ScreenList 사용) +
+ +
+ )} + + {/* 화면 생성 모달 */} + setIsCreateOpen(false)} + onSuccess={() => { + setIsCreateOpen(false); + loadScreens(); + }} + /> + {/* Scroll to Top 버튼 */}
diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx new file mode 100644 index 00000000..fee877e9 --- /dev/null +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { ChevronRight, ChevronDown, Monitor, FolderOpen, Folder } from "lucide-react"; +import { ScreenDefinition } from "@/types/screen"; +import { ScreenGroup, getScreenGroups } from "@/lib/api/screenGroup"; +import { Badge } from "@/components/ui/badge"; + +interface ScreenGroupTreeViewProps { + screens: ScreenDefinition[]; + selectedScreen: ScreenDefinition | null; + onScreenSelect: (screen: ScreenDefinition) => void; + onScreenDesign: (screen: ScreenDefinition) => void; + companyCode?: string; +} + +interface TreeNode { + type: "group" | "screen"; + id: string; + name: string; + data?: ScreenDefinition | ScreenGroup; + children?: TreeNode[]; + expanded?: boolean; +} + +export function ScreenGroupTreeView({ + screens, + selectedScreen, + onScreenSelect, + onScreenDesign, + companyCode, +}: ScreenGroupTreeViewProps) { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [groupScreensMap, setGroupScreensMap] = useState>(new Map()); + + // 그룹 목록 로드 + useEffect(() => { + const loadGroups = async () => { + try { + setLoading(true); + const response = await getScreenGroups({}); + if (response.success && response.data) { + setGroups(response.data); + } + } catch (error) { + console.error("그룹 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadGroups(); + }, [companyCode]); + + // 그룹에 속한 화면 ID들을 가져오기 + const getGroupedScreenIds = (): Set => { + const ids = new Set(); + groupScreensMap.forEach((screenIds) => { + screenIds.forEach((id) => ids.add(id)); + }); + return ids; + }; + + // 미분류 화면들 (어떤 그룹에도 속하지 않은 화면) + const getUngroupedScreens = (): ScreenDefinition[] => { + const groupedIds = getGroupedScreenIds(); + return screens.filter((screen) => !groupedIds.has(screen.screenId)); + }; + + // 그룹에 속한 화면들 + const getScreensInGroup = (groupId: number): ScreenDefinition[] => { + const screenIds = groupScreensMap.get(groupId) || []; + return screens.filter((screen) => screenIds.includes(screen.screenId)); + }; + + const toggleGroup = (groupId: string) => { + const newExpanded = new Set(expandedGroups); + if (newExpanded.has(groupId)) { + newExpanded.delete(groupId); + } else { + newExpanded.add(groupId); + } + setExpandedGroups(newExpanded); + }; + + const handleScreenClick = (screen: ScreenDefinition) => { + onScreenSelect(screen); + }; + + const handleScreenDoubleClick = (screen: ScreenDefinition) => { + onScreenDesign(screen); + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + const ungroupedScreens = getUngroupedScreens(); + + return ( +
+
+ {/* 그룹화된 화면들 */} + {groups.map((group) => { + const groupId = String(group.id); + const isExpanded = expandedGroups.has(groupId); + const groupScreens = getScreensInGroup(group.id); + + return ( +
+ {/* 그룹 헤더 */} +
toggleGroup(groupId)} + > + {isExpanded ? ( + + ) : ( + + )} + {isExpanded ? ( + + ) : ( + + )} + {group.groupName} + + {groupScreens.length} + +
+ + {/* 그룹 내 화면들 */} + {isExpanded && ( +
+ {groupScreens.length === 0 ? ( +
+ 화면이 없습니다 +
+ ) : ( + groupScreens.map((screen) => ( +
handleScreenClick(screen)} + onDoubleClick={() => handleScreenDoubleClick(screen)} + > + + {screen.screenName} + + {screen.screenCode} + +
+ )) + )} +
+ )} +
+ ); + })} + + {/* 미분류 화면들 */} + {ungroupedScreens.length > 0 && ( +
+
toggleGroup("ungrouped")} + > + {expandedGroups.has("ungrouped") ? ( + + ) : ( + + )} + + 미분류 + + {ungroupedScreens.length} + +
+ + {expandedGroups.has("ungrouped") && ( +
+ {ungroupedScreens.map((screen) => ( +
handleScreenClick(screen)} + onDoubleClick={() => handleScreenDoubleClick(screen)} + > + + {screen.screenName} + + {screen.screenCode} + +
+ ))} +
+ )} +
+ )} + + {groups.length === 0 && ungroupedScreens.length === 0 && ( +
+ +

등록된 화면이 없습니다

+
+ )} +
+
+ ); +} + diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 916fe60e..74fdae7d 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -42,6 +42,8 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateC import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; +import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup"; +import { Layers } from "lucide-react"; import CreateScreenModal from "./CreateScreenModal"; import CopyScreenModal from "./CopyScreenModal"; import dynamic from "next/dynamic"; @@ -93,6 +95,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [isCopyOpen, setIsCopyOpen] = useState(false); const [screenToCopy, setScreenToCopy] = useState(null); + // 그룹 필터 관련 상태 + const [selectedGroupId, setSelectedGroupId] = useState("all"); + const [groups, setGroups] = useState([]); + const [loadingGroups, setLoadingGroups] = useState(false); + // 검색어 디바운스를 위한 타이머 ref const debounceTimer = useRef(null); @@ -183,6 +190,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } }; + // 화면 그룹 목록 로드 + useEffect(() => { + loadGroups(); + }, []); + + const loadGroups = async () => { + try { + setLoadingGroups(true); + const response = await getScreenGroups(); + if (response.success && response.data) { + setGroups(response.data); + } + } catch (error) { + console.error("그룹 목록 조회 실패:", error); + } finally { + setLoadingGroups(false); + } + }; + // 검색어 디바운스 처리 (150ms 지연 - 빠른 응답) useEffect(() => { // 이전 타이머 취소 @@ -224,6 +250,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr params.companyCode = selectedCompanyCode; } + // 그룹 필터 + if (selectedGroupId !== "all") { + params.groupId = selectedGroupId; + } + console.log("🔍 화면 목록 API 호출:", params); // 디버깅용 const resp = await screenApi.getScreens(params); console.log("✅ 화면 목록 응답:", resp); // 디버깅용 @@ -256,7 +287,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr return () => { abort = true; }; - }, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]); + }, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]); const filteredScreens = screens; // 서버 필터 기준 사용 @@ -671,6 +702,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
)} + {/* 그룹 필터 */} +
+ +
+ {/* 검색 입력 */}
diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx new file mode 100644 index 00000000..dcf2658f --- /dev/null +++ b/frontend/components/screen/ScreenNode.tsx @@ -0,0 +1,446 @@ +"use client"; + +import React from "react"; +import { Handle, Position } from "@xyflow/react"; +import { + Monitor, + Database, + FormInput, + Table2, + LayoutDashboard, + MousePointer2, + Key, + Link2, + Columns3, +} from "lucide-react"; +import { ScreenLayoutSummary } from "@/lib/api/screenGroup"; + +// ========== 타입 정의 ========== + +// 화면 노드 데이터 인터페이스 +export interface ScreenNodeData { + label: string; + subLabel?: string; + type: "screen" | "table" | "action"; + tableName?: string; + isMain?: boolean; + // 레이아웃 요약 정보 (미리보기용) + layoutSummary?: ScreenLayoutSummary; +} + +// 테이블 노드 데이터 인터페이스 +export interface TableNodeData { + label: string; + subLabel?: string; + isMain?: boolean; + columns?: Array<{ + name: string; + type: string; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + }>; +} + +// ========== 유틸리티 함수 ========== + +// 화면 타입별 아이콘 +const getScreenTypeIcon = (screenType?: string) => { + switch (screenType) { + case "grid": + return ; + case "dashboard": + return ; + case "action": + return ; + default: + return ; + } +}; + +// 화면 타입별 색상 (헤더) +const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { + if (!isMain) return "bg-slate-400"; + switch (screenType) { + case "grid": + return "bg-violet-500"; + case "dashboard": + return "bg-amber-500"; + case "action": + return "bg-rose-500"; + default: + return "bg-blue-500"; + } +}; + +// 화면 타입별 라벨 +const getScreenTypeLabel = (screenType?: string) => { + switch (screenType) { + case "grid": + return "그리드"; + case "dashboard": + return "대시보드"; + case "action": + return "액션"; + default: + return "폼"; + } +}; + +// ========== 화면 노드 (상단) - 미리보기 표시 ========== +export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { + const { label, subLabel, isMain, tableName, layoutSummary } = data; + const screenType = layoutSummary?.screenType || "form"; + const headerColor = getScreenTypeColor(screenType, isMain); + + return ( +
+ {/* Handles */} + + + + + {/* 헤더 (컬러) */} +
+ + {label} + {isMain && } +
+ + {/* 화면 미리보기 영역 (컴팩트) */} +
+ {layoutSummary ? ( + + ) : ( +
+ {getScreenTypeIcon(screenType)} + 화면: {label} +
+ )} +
+ + {/* 필드 매핑 영역 */} +
+
+ + 필드 매핑 + + {layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}개 + +
+
+ {layoutSummary?.layoutItems + ?.filter(item => item.label && !item.componentKind?.includes('button')) + ?.slice(0, 6) + ?.map((item, idx) => ( +
+
+ {item.label} + {item.componentKind?.split('-')[0] || 'field'} +
+ )) || ( +
필드 정보 없음
+ )} +
+
+ + {/* 푸터 (테이블 정보) */} +
+
+ + {tableName || "No Table"} +
+ + {getScreenTypeLabel(screenType)} + +
+
+ ); +}; + +// ========== 컴포넌트 종류별 미니어처 색상 ========== +// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등) +const getComponentColor = (componentKind: string) => { + // 테이블/그리드 관련 + if (componentKind === "table-list" || componentKind === "data-grid") { + return "bg-violet-200 border-violet-400"; + } + // 검색 필터 + if (componentKind === "table-search-widget" || componentKind === "search-filter") { + return "bg-pink-200 border-pink-400"; + } + // 버튼 관련 + if (componentKind?.includes("button")) { + return "bg-blue-300 border-blue-500"; + } + // 입력 필드 + if (componentKind?.includes("input") || componentKind?.includes("text")) { + return "bg-slate-200 border-slate-400"; + } + // 셀렉트/드롭다운 + if (componentKind?.includes("select") || componentKind?.includes("dropdown")) { + return "bg-amber-200 border-amber-400"; + } + // 차트 + if (componentKind?.includes("chart")) { + return "bg-emerald-200 border-emerald-400"; + } + // 커스텀 위젯 + if (componentKind === "custom") { + return "bg-pink-200 border-pink-400"; + } + return "bg-slate-100 border-slate-300"; +}; + +// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ========== +const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: string }> = ({ + layoutSummary, + screenType, +}) => { + const { totalComponents, widgetCounts } = layoutSummary; + + // 그리드 화면 일러스트 + if (screenType === "grid") { + return ( +
+ {/* 상단 툴바 */} +
+
+
+
+
+
+
+ {/* 테이블 헤더 */} +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ {/* 테이블 행들 */} +
+ {[...Array(7)].map((_, i) => ( +
+ {[...Array(5)].map((_, j) => ( +
+ ))} +
+ ))} +
+ {/* 페이지네이션 */} +
+
+
+
+
+
+ {/* 컴포넌트 수 */} +
+ {totalComponents}개 +
+
+ ); + } + + // 폼 화면 일러스트 + if (screenType === "form") { + return ( +
+ {/* 폼 필드들 */} + {[...Array(6)].map((_, i) => ( +
+
+
+
+ ))} + {/* 버튼 영역 */} +
+
+
+
+ {/* 컴포넌트 수 */} +
+ {totalComponents}개 +
+
+ ); + } + + // 대시보드 화면 일러스트 + if (screenType === "dashboard") { + return ( +
+ {/* 카드/차트들 */} +
+
+
+
+
+
+
+
+
+
+
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+
+ {/* 컴포넌트 수 */} +
+ {totalComponents}개 +
+
+ ); + } + + // 액션 화면 일러스트 (버튼 중심) + if (screenType === "action") { + return ( +
+
+ +
+
+
+
+
+
액션 화면
+ {/* 컴포넌트 수 */} +
+ {totalComponents}개 +
+
+ ); + } + + // 기본 (알 수 없는 타입) + return ( +
+
+ {getScreenTypeIcon(screenType)} +
+ {totalComponents}개 컴포넌트 +
+ ); +}; + +// ========== 테이블 노드 (하단) - 컬럼 목록 표시 ========== +export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { + const { label, subLabel, isMain, columns } = data; + + return ( +
+ {/* Handles */} + + + + + {/* 헤더 (초록색) */} +
+ +
+
{label}
+ {subLabel &&
{subLabel}
} +
+
+ + {/* 컬럼 목록 */} +
+ {columns && columns.length > 0 ? ( +
+ {columns.map((col, idx) => ( +
+ {/* PK/FK 아이콘 */} + {col.isPrimaryKey && } + {col.isForeignKey && !col.isPrimaryKey && } + {!col.isPrimaryKey && !col.isForeignKey &&
} + + {/* 컬럼명 */} + + {col.name} + + + {/* 타입 */} + {col.type} +
+ ))} + + {columns.length > 8 && ( +
... 외 {columns.length - 8}개 컬럼
+ )} +
+ ) : ( +
+ + 컬럼 정보 없음 +
+ )} +
+ + {/* 푸터 */} +
+ PostgreSQL + {columns && ( + {columns.length}개 컬럼 + )} +
+
+ ); +}; + +// ========== 기존 호환성 유지용 ========== +export const LegacyScreenNode = ScreenNode; +export const AggregateNode: React.FC<{ data: any }> = ({ data }) => { + return ( +
+ + +
+ + {data.label || "Aggregate"} +
+
+ ); +}; diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx new file mode 100644 index 00000000..d3dd737a --- /dev/null +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -0,0 +1,367 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + ReactFlow, + Controls, + Background, + BackgroundVariant, + Node, + Edge, + useNodesState, + useEdgesState, + MarkerType, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; + +import { ScreenDefinition } from "@/types/screen"; +import { ScreenNode, TableNode, ScreenNodeData, TableNodeData } from "./ScreenNode"; +import { + getFieldJoins, + getDataFlows, + getTableRelations, + getMultipleScreenLayoutSummary, + ScreenLayoutSummary, +} from "@/lib/api/screenGroup"; +import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement"; + +// 노드 타입 등록 +const nodeTypes = { + screenNode: ScreenNode, + tableNode: TableNode, +}; + +// 레이아웃 상수 +const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단) +const TABLE_Y = 400; // 테이블 노드 Y 위치 (하단) +const NODE_WIDTH = 260; // 노드 너비 (조금 넓게) +const NODE_GAP = 40; // 노드 간격 + +interface ScreenRelationFlowProps { + screen: ScreenDefinition | null; +} + +// 노드 타입 (Record 확장) +type ScreenNodeType = Node>; +type TableNodeType = Node>; +type AllNodeType = ScreenNodeType | TableNodeType; + +export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [loading, setLoading] = useState(false); + const [tableColumns, setTableColumns] = useState>({}); + + // 테이블 컬럼 정보 로드 + const loadTableColumns = useCallback( + async (tableName: string): Promise => { + if (!tableName) return []; + if (tableColumns[tableName]) return tableColumns[tableName]; + + try { + const response = await getTableColumns(tableName); + if (response.success && response.data && response.data.columns) { + const columns = response.data.columns; + setTableColumns((prev) => ({ ...prev, [tableName]: columns })); + return columns; + } + } catch (error) { + console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); + } + return []; + }, + [tableColumns] + ); + + // 데이터 로드 및 노드/엣지 생성 + useEffect(() => { + if (!screen) { + setNodes([]); + setEdges([]); + return; + } + + const loadRelations = async () => { + setLoading(true); + + try { + // 관계 데이터 로드 + const [joinsRes, flowsRes, relationsRes] = await Promise.all([ + getFieldJoins(screen.screenId).catch(() => ({ success: false, data: [] })), + getDataFlows().catch(() => ({ success: false, data: [] })), + getTableRelations({ screen_id: screen.screenId }).catch(() => ({ success: false, data: [] })), + ]); + + const joins = joinsRes.success ? joinsRes.data || [] : []; + const flows = flowsRes.success ? flowsRes.data || [] : []; + const relations = relationsRes.success ? relationsRes.data || [] : []; + + // ========== 화면 목록 수집 ========== + const screenList: ScreenDefinition[] = [screen]; + + // 데이터 흐름에서 연결된 화면들 추가 + flows.forEach((flow: any) => { + if (flow.source_screen_id === screen.screenId && flow.target_screen_id) { + const exists = screenList.some((s) => s.screenId === flow.target_screen_id); + if (!exists) { + screenList.push({ + screenId: flow.target_screen_id, + screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`, + screenCode: "", + tableName: "", + companyCode: screen.companyCode, + isActive: "Y", + createdDate: new Date(), + updatedDate: new Date(), + } as ScreenDefinition); + } + } + }); + + // 화면 레이아웃 요약 정보 로드 + const screenIds = screenList.map((s) => s.screenId); + let layoutSummaries: Record = {}; + try { + const layoutRes = await getMultipleScreenLayoutSummary(screenIds); + if (layoutRes.success && layoutRes.data) { + // API 응답이 Record 형태 (screenId -> summary) + layoutSummaries = layoutRes.data as Record; + } + } catch (e) { + console.error("레이아웃 요약 로드 실패:", e); + } + + // ========== 상단: 화면 노드들 ========== + const screenNodes: ScreenNodeType[] = []; + const screenStartX = 50; + + screenList.forEach((scr, idx) => { + const isMain = scr.screenId === screen.screenId; + const summary = layoutSummaries[scr.screenId]; + + screenNodes.push({ + id: `screen-${scr.screenId}`, + type: "screenNode", + position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y }, + data: { + label: scr.screenName, + subLabel: isMain ? "메인 화면" : "연결 화면", + type: "screen", + isMain, + tableName: scr.tableName, + layoutSummary: summary, + }, + }); + }); + + // ========== 하단: 테이블 노드들 ========== + const tableNodes: TableNodeType[] = []; + const tableSet = new Set(); + + // 메인 화면의 테이블 추가 + if (screen.tableName) { + tableSet.add(screen.tableName); + } + + // 조인된 테이블들 추가 + joins.forEach((join: any) => { + if (join.save_table) tableSet.add(join.save_table); + if (join.join_table) tableSet.add(join.join_table); + }); + + // 테이블 관계에서 추가 + relations.forEach((rel: any) => { + if (rel.table_name) tableSet.add(rel.table_name); + }); + + // 테이블 노드 배치 (하단, 가로 배치) + const tableList = Array.from(tableSet); + const tableStartX = 50; + + for (let idx = 0; idx < tableList.length; idx++) { + const tableName = tableList[idx]; + const isMainTable = tableName === screen.tableName; + + // 컬럼 정보 로드 + let columns: ColumnTypeInfo[] = []; + try { + columns = await loadTableColumns(tableName); + } catch (e) { + // ignore + } + + // 컬럼 정보를 PK/FK 표시와 함께 변환 + const formattedColumns = columns.slice(0, 8).map((col) => ({ + name: col.displayName || col.columnName || "", + type: col.dataType || "", + isPrimaryKey: col.isPrimaryKey || col.columnName === "id", + isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"), + })); + + tableNodes.push({ + id: `table-${tableName}`, + type: "tableNode", + position: { x: tableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y }, + data: { + label: tableName, + subLabel: isMainTable ? "메인 테이블" : "조인 테이블", + isMain: isMainTable, + columns: formattedColumns, + }, + }); + } + + // ========== 엣지: 연결선 생성 ========== + const newEdges: Edge[] = []; + + // 메인 화면 → 메인 테이블 연결 (양방향 CRUD) + if (screen.tableName) { + newEdges.push({ + id: `edge-main`, + source: `screen-${screen.screenId}`, + target: `table-${screen.tableName}`, + sourceHandle: "bottom", + targetHandle: "top", + type: "smoothstep", + animated: true, + style: { stroke: "#3b82f6", strokeWidth: 2 }, + }); + } + + // 조인 관계 엣지 (테이블 간 - 1:N 관계 표시) + joins.forEach((join: any, idx: number) => { + if (join.save_table && join.join_table && join.save_table !== join.join_table) { + newEdges.push({ + id: `edge-join-${idx}`, + source: `table-${join.save_table}`, + target: `table-${join.join_table}`, + sourceHandle: "right", + targetHandle: "left", + type: "smoothstep", + label: "1:N 관계", + labelStyle: { fontSize: 10, fill: "#6366f1", fontWeight: 500 }, + labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 }, + labelBgPadding: [4, 2] as [number, number], + markerEnd: { type: MarkerType.ArrowClosed, color: "#6366f1" }, + style: { stroke: "#6366f1", strokeWidth: 1.5, strokeDasharray: "5,5" }, + }); + } + }); + + // 테이블 관계 엣지 (추가 관계) + relations.forEach((rel: any, idx: number) => { + if (rel.table_name && rel.table_name !== screen.tableName) { + // 화면 → 연결 테이블 + const edgeExists = newEdges.some( + (e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}` + ); + if (!edgeExists) { + newEdges.push({ + id: `edge-rel-${idx}`, + source: `screen-${screen.screenId}`, + target: `table-${rel.table_name}`, + sourceHandle: "bottom", + targetHandle: "top", + type: "smoothstep", + label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "", + labelStyle: { fontSize: 9, fill: "#10b981" }, + labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 }, + labelBgPadding: [3, 2] as [number, number], + style: { stroke: "#10b981", strokeWidth: 1.5 }, + }); + } + } + }); + + // 데이터 흐름 엣지 (화면 간) + flows + .filter((flow: any) => flow.source_screen_id === screen.screenId) + .forEach((flow: any, idx: number) => { + if (flow.target_screen_id) { + newEdges.push({ + id: `edge-flow-${idx}`, + source: `screen-${screen.screenId}`, + target: `screen-${flow.target_screen_id}`, + sourceHandle: "right", + targetHandle: "left", + type: "smoothstep", + animated: true, + label: flow.flow_label || flow.flow_type || "이동", + labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 }, + labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 }, + labelBgPadding: [4, 2] as [number, number], + markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" }, + style: { stroke: "#8b5cf6", strokeWidth: 2 }, + }); + } + }); + + // 최종 노드 배열 합치기 + const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes]; + + // 테이블이 없으면 안내 노드 추가 + if (tableNodes.length === 0) { + allNodes.push({ + id: "hint-table", + type: "tableNode", + position: { x: 50, y: TABLE_Y }, + data: { + label: "연결된 테이블 없음", + subLabel: "화면에 테이블을 설정하세요", + isMain: false, + columns: [], + }, + }); + } + + setNodes(allNodes); + setEdges(newEdges); + } catch (error) { + console.error("관계 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadRelations(); + }, [screen, setNodes, setEdges, loadTableColumns]); + + if (!screen) { + return ( +
+
+

화면을 선택하면

+

데이터 관계가 시각화됩니다

+
+
+ ); + } + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/frontend/components/screen/ScreenRelationView.tsx b/frontend/components/screen/ScreenRelationView.tsx new file mode 100644 index 00000000..56e98e8c --- /dev/null +++ b/frontend/components/screen/ScreenRelationView.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { ScreenDefinition } from "@/types/screen"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + Database, + Monitor, + ArrowRight, + Link2, + Table, + Columns, + ExternalLink, + Layers, + GitBranch +} from "lucide-react"; +import { getFieldJoins, getDataFlows, getTableRelations, FieldJoin, DataFlow, TableRelation } from "@/lib/api/screenGroup"; +import { screenApi } from "@/lib/api/screen"; + +interface ScreenRelationViewProps { + screen: ScreenDefinition | null; +} + +export function ScreenRelationView({ screen }: ScreenRelationViewProps) { + const [loading, setLoading] = useState(false); + const [fieldJoins, setFieldJoins] = useState([]); + const [dataFlows, setDataFlows] = useState([]); + const [tableRelations, setTableRelations] = useState([]); + const [layoutInfo, setLayoutInfo] = useState(null); + + useEffect(() => { + const loadRelations = async () => { + if (!screen) { + setFieldJoins([]); + setDataFlows([]); + setTableRelations([]); + setLayoutInfo(null); + return; + } + + try { + setLoading(true); + + // 병렬로 데이터 로드 + const [joinsRes, flowsRes, relationsRes, layoutRes] = await Promise.all([ + getFieldJoins(screen.screenId), + getDataFlows(screen.screenId), + getTableRelations(screen.screenId), + screenApi.getLayout(screen.screenId).catch(() => null), + ]); + + if (joinsRes.success && joinsRes.data) { + setFieldJoins(joinsRes.data); + } + if (flowsRes.success && flowsRes.data) { + setDataFlows(flowsRes.data); + } + if (relationsRes.success && relationsRes.data) { + setTableRelations(relationsRes.data); + } + if (layoutRes) { + setLayoutInfo(layoutRes); + } + } catch (error) { + console.error("관계 정보 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadRelations(); + }, [screen?.screenId]); + + if (!screen) { + return ( +
+ +

화면을 선택하세요

+

+ 왼쪽 트리에서 화면을 선택하면 데이터 관계가 표시됩니다 +

+
+ ); + } + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + // 컴포넌트에서 사용하는 테이블 분석 + const getUsedTables = () => { + const tables = new Set(); + if (screen.tableName) { + tables.add(screen.tableName); + } + if (layoutInfo?.components) { + layoutInfo.components.forEach((comp: any) => { + if (comp.properties?.tableName) { + tables.add(comp.properties.tableName); + } + if (comp.properties?.dataSource?.tableName) { + tables.add(comp.properties.dataSource.tableName); + } + }); + } + return Array.from(tables); + }; + + const usedTables = getUsedTables(); + + return ( +
+ {/* 화면 기본 정보 */} +
+
+ +
+
+

{screen.screenName}

+
+ {screen.screenCode} + {screen.screenType} +
+ {screen.description && ( +

+ {screen.description} +

+ )} +
+
+ + + + {/* 연결된 테이블 */} + + + + + 연결된 테이블 + + + + {usedTables.length > 0 ? ( +
+ {usedTables.map((tableName, index) => ( +
+ + {tableName} + {tableName === screen.tableName && ( + + 메인 + + )} + + ))} + + ) : ( +

연결된 테이블이 없습니다

+ )} + + + + {/* 필드 조인 관계 */} + + + + + 필드 조인 관계 + {fieldJoins.length > 0 && ( + {fieldJoins.length} + )} + + + + {fieldJoins.length > 0 ? ( +
+ {fieldJoins.map((join) => ( +
+
+ {join.sourceTable} + . + {join.sourceColumn} +
+ +
+ {join.targetTable} + . + {join.targetColumn} +
+ + {join.joinType} + +
+ ))} +
+ ) : ( +

설정된 조인이 없습니다

+ )} +
+
+ + {/* 데이터 흐름 */} + + + + + 데이터 흐름 + {dataFlows.length > 0 && ( + {dataFlows.length} + )} + + + + {dataFlows.length > 0 ? ( +
+ {dataFlows.map((flow) => ( +
+ + {flow.flowName || "이름 없음"} + + + + 화면 #{flow.targetScreenId} + + + {flow.flowType} + +
+ ))} +
+ ) : ( +

설정된 데이터 흐름이 없습니다

+ )} +
+
+ + {/* 테이블 관계 */} + + + + + 테이블 관계 + {tableRelations.length > 0 && ( + {tableRelations.length} + )} + + + + {tableRelations.length > 0 ? ( +
+ {tableRelations.map((relation) => ( +
+
+ {relation.parentTable} + + {relation.childTable} + + {relation.relationType} + + + ))} + + ) : ( +

설정된 테이블 관계가 없습니다

+ )} + + + + {/* 빠른 작업 */} +
+

+ 더블클릭하면 화면 디자이너로 이동합니다 +

+
+ + ); +} + diff --git a/frontend/components/screen/panels/DataFlowPanel.tsx b/frontend/components/screen/panels/DataFlowPanel.tsx new file mode 100644 index 00000000..d28bf7c3 --- /dev/null +++ b/frontend/components/screen/panels/DataFlowPanel.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "sonner"; +import { Plus, ArrowRight, Trash2, Pencil, GitBranch, RefreshCw } from "lucide-react"; +import { + getDataFlows, + createDataFlow, + updateDataFlow, + deleteDataFlow, + DataFlow, +} from "@/lib/api/screenGroup"; + +interface DataFlowPanelProps { + groupId?: number; + screenId?: number; + screens?: Array<{ screen_id: number; screen_name: string }>; +} + +export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataFlowPanelProps) { + // 상태 관리 + const [dataFlows, setDataFlows] = useState([]); + const [loading, setLoading] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedFlow, setSelectedFlow] = useState(null); + const [formData, setFormData] = useState({ + source_screen_id: 0, + source_action: "", + target_screen_id: 0, + target_action: "", + data_mapping: "", + flow_type: "unidirectional", + flow_label: "", + condition_expression: "", + is_active: "Y", + }); + + // 데이터 로드 + const loadDataFlows = useCallback(async () => { + setLoading(true); + try { + const response = await getDataFlows(groupId); + if (response.success && response.data) { + setDataFlows(response.data); + } + } catch (error) { + console.error("데이터 흐름 로드 실패:", error); + } finally { + setLoading(false); + } + }, [groupId]); + + useEffect(() => { + loadDataFlows(); + }, [loadDataFlows]); + + // 모달 열기 + const openModal = (flow?: DataFlow) => { + if (flow) { + setSelectedFlow(flow); + setFormData({ + source_screen_id: flow.source_screen_id, + source_action: flow.source_action || "", + target_screen_id: flow.target_screen_id, + target_action: flow.target_action || "", + data_mapping: flow.data_mapping ? JSON.stringify(flow.data_mapping, null, 2) : "", + flow_type: flow.flow_type, + flow_label: flow.flow_label || "", + condition_expression: flow.condition_expression || "", + is_active: flow.is_active, + }); + } else { + setSelectedFlow(null); + setFormData({ + source_screen_id: screenId || 0, + source_action: "", + target_screen_id: 0, + target_action: "", + data_mapping: "", + flow_type: "unidirectional", + flow_label: "", + condition_expression: "", + is_active: "Y", + }); + } + setIsModalOpen(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.source_screen_id || !formData.target_screen_id) { + toast.error("소스 화면과 타겟 화면을 선택해주세요."); + return; + } + + try { + let dataMappingJson = null; + if (formData.data_mapping) { + try { + dataMappingJson = JSON.parse(formData.data_mapping); + } catch { + toast.error("데이터 매핑 JSON 형식이 올바르지 않습니다."); + return; + } + } + + const payload = { + group_id: groupId, + source_screen_id: formData.source_screen_id, + source_action: formData.source_action || null, + target_screen_id: formData.target_screen_id, + target_action: formData.target_action || null, + data_mapping: dataMappingJson, + flow_type: formData.flow_type, + flow_label: formData.flow_label || null, + condition_expression: formData.condition_expression || null, + is_active: formData.is_active, + }; + + let response; + if (selectedFlow) { + response = await updateDataFlow(selectedFlow.id, payload); + } else { + response = await createDataFlow(payload); + } + + if (response.success) { + toast.success(selectedFlow ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다."); + setIsModalOpen(false); + loadDataFlows(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("이 데이터 흐름을 삭제하시겠습니까?")) return; + + try { + const response = await deleteDataFlow(id); + if (response.success) { + toast.success("데이터 흐름이 삭제되었습니다."); + loadDataFlows(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다."); + } + }; + + // 액션 옵션 + const sourceActions = [ + { value: "click", label: "클릭" }, + { value: "submit", label: "제출" }, + { value: "select", label: "선택" }, + { value: "change", label: "변경" }, + { value: "doubleClick", label: "더블클릭" }, + ]; + + const targetActions = [ + { value: "open", label: "열기" }, + { value: "load", label: "로드" }, + { value: "refresh", label: "새로고침" }, + { value: "save", label: "저장" }, + { value: "filter", label: "필터" }, + ]; + + return ( +
+ {/* 헤더 */} +
+
+ +

데이터 흐름

+
+
+ + +
+
+ + {/* 설명 */} +

+ 화면 간 데이터 전달 흐름을 정의합니다. (예: 목록 화면에서 행 클릭 시 상세 화면 열기) +

+ + {/* 흐름 목록 */} + {loading ? ( +
+
+
+ ) : dataFlows.length === 0 ? ( +
+ +

정의된 데이터 흐름이 없습니다

+
+ ) : ( +
+ {dataFlows.map((flow) => ( +
+
+ {/* 소스 화면 */} +
+ + {flow.source_screen_name || `화면 ${flow.source_screen_id}`} + + {flow.source_action && ( + {flow.source_action} + )} +
+ + {/* 화살표 */} +
+ + {flow.flow_type === "bidirectional" && ( + + )} +
+ + {/* 타겟 화면 */} +
+ + {flow.target_screen_name || `화면 ${flow.target_screen_id}`} + + {flow.target_action && ( + {flow.target_action} + )} +
+ + {/* 라벨 */} + {flow.flow_label && ( + + {flow.flow_label} + + )} +
+ + {/* 액션 버튼 */} +
+ + +
+
+ ))} +
+ )} + + {/* 추가/수정 모달 */} + + + + + {selectedFlow ? "데이터 흐름 수정" : "데이터 흐름 추가"} + + + 화면 간 데이터 전달 흐름을 설정합니다 + + + +
+ {/* 소스 화면 */} +
+
+ + +
+
+ + +
+
+ + {/* 타겟 화면 */} +
+
+ + +
+
+ + +
+
+ + {/* 흐름 설정 */} +
+
+ + +
+
+ + setFormData({ ...formData, flow_label: e.target.value })} + placeholder="예: 상세 보기" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 데이터 매핑 */} +
+ +