diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 7e1108c3..43b698d2 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -1044,6 +1044,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2371,6 +2372,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3474,6 +3476,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3710,6 +3713,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3927,6 +3931,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4453,6 +4458,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5663,6 +5669,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7425,6 +7432,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8394,7 +8402,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9283,6 +9290,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -10133,7 +10141,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -10942,6 +10949,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11047,6 +11055,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 80e406b9..3e8f63f1 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -73,6 +73,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"; // 세금계산서 관리 @@ -197,6 +198,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/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index ed2576cd..ab9bbc46 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -70,11 +70,23 @@ export class EntityJoinController { const userField = parsedAutoFilter.userField || "companyCode"; const userValue = ((req as any).user as any)[userField]; - if (userValue) { - searchConditions[filterColumn] = userValue; + // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) + let finalCompanyCode = userValue; + if (parsedAutoFilter.companyCodeOverride && userValue === "*") { + // 최고 관리자만 다른 회사 코드로 오버라이드 가능 + finalCompanyCode = parsedAutoFilter.companyCodeOverride; + logger.info("🔓 최고 관리자 회사 코드 오버라이드:", { + originalCompanyCode: userValue, + overrideCompanyCode: parsedAutoFilter.companyCodeOverride, + tableName, + }); + } + + if (finalCompanyCode) { + searchConditions[filterColumn] = finalCompanyCode; logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { filterColumn, - userValue, + finalCompanyCode, tableName, }); } diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts new file mode 100644 index 00000000..52464ed4 --- /dev/null +++ b/backend-node/src/controllers/screenGroupController.ts @@ -0,0 +1,2004 @@ +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); + + // 데이터 조회 (screens 배열 포함) + const dataQuery = ` + SELECT + sg.*, + (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (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, + 'table_name', sd.table_name + ) 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 + ${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, + 'table_name', sd.table_name + ) 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 userCompanyCode = (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, parent_group_id, target_company_code } = req.body; + + if (!group_name || !group_code) { + return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." }); + } + + // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 사용자 회사 + let finalCompanyCode = userCompanyCode; + if (userCompanyCode === "*" && target_company_code) { + // 최고 관리자가 특정 회사를 선택한 경우 + finalCompanyCode = target_company_code; + } + + // 부모 그룹이 있으면 group_level과 hierarchy_path 계산 + let groupLevel = 0; + let parentHierarchyPath = ""; + + if (parent_group_id) { + const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; + const parentResult = await pool.query(parentQuery, [parent_group_id]); + if (parentResult.rows.length > 0) { + groupLevel = (parentResult.rows[0].group_level || 0) + 1; + parentHierarchyPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; + } + } + + const query = ` + INSERT INTO screen_groups (group_name, group_code, main_table_name, description, icon, display_order, is_active, company_code, writer, parent_group_id, group_level) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `; + const params = [ + group_name, + group_code, + main_table_name || null, + description || null, + icon || null, + display_order || 0, + is_active || 'Y', + finalCompanyCode, + userId, + parent_group_id || null, + groupLevel + ]; + + const result = await pool.query(query, params); + const newGroupId = result.rows[0].id; + + // hierarchy_path 업데이트 + const hierarchyPath = parent_group_id + ? `${parentHierarchyPath}${newGroupId}/`.replace('//', '/') + : `/${newGroupId}/`; + await pool.query(`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, [hierarchyPath, newGroupId]); + + // 업데이트된 데이터 반환 + const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); + + logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); + + res.json({ success: true, data: updatedResult.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 userCompanyCode = (req.user as any).companyCode; + const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; + + // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 + let finalCompanyCode = target_company_code || null; + + // 부모 그룹이 변경되면 group_level과 hierarchy_path 재계산 + let groupLevel = 0; + let hierarchyPath = `/${id}/`; + + if (parent_group_id !== undefined && parent_group_id !== null) { + // 자기 자신을 부모로 지정하는 것 방지 + if (Number(parent_group_id) === Number(id)) { + return res.status(400).json({ success: false, message: "자기 자신을 상위 그룹으로 지정할 수 없습니다." }); + } + + const parentQuery = `SELECT id, group_level, hierarchy_path FROM screen_groups WHERE id = $1`; + const parentResult = await pool.query(parentQuery, [parent_group_id]); + if (parentResult.rows.length > 0) { + // 순환 참조 방지: 부모의 hierarchy_path에 현재 그룹 ID가 포함되어 있으면 오류 + if (parentResult.rows[0].hierarchy_path && parentResult.rows[0].hierarchy_path.includes(`/${id}/`)) { + return res.status(400).json({ success: false, message: "하위 그룹을 상위 그룹으로 지정할 수 없습니다." }); + } + groupLevel = (parentResult.rows[0].group_level || 0) + 1; + const parentPath = parentResult.rows[0].hierarchy_path || `/${parent_group_id}/`; + hierarchyPath = `${parentPath}${id}/`.replace('//', '/'); + } + } + + // 쿼리 구성: 회사 코드 변경 포함 여부 + let query: string; + let params: any[]; + + if (userCompanyCode === "*" && finalCompanyCode) { + // 최고 관리자가 회사를 변경하는 경우 + 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(), + parent_group_id = $8, group_level = $9, hierarchy_path = $10, company_code = $11 + WHERE id = $12 + `; + params = [ + group_name, group_code, main_table_name, description, icon, display_order, is_active, + parent_group_id || null, groupLevel, hierarchyPath, finalCompanyCode, id + ]; + } else { + // 회사 코드 변경 없음 + 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(), + parent_group_id = $8, group_level = $9, hierarchy_path = $10 + WHERE id = $11 + `; + params = [ + group_name, group_code, main_table_name, description, icon, display_order, is_active, + parent_group_id || null, groupLevel, hierarchyPath, id + ]; + } + + // 멀티테넌시 필터링 (최고 관리자가 아닌 경우) + if (userCompanyCode !== "*") { + const paramIndex = params.length + 1; + query += ` AND company_code = $${paramIndex}`; + params.push(userCompanyCode); + } + + query += " RETURNING *"; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); + } + + logger.info("화면 그룹 수정", { userCompanyCode, groupId: id, parentGroupId: parent_group_id, targetCompanyCode: finalCompanyCode }); + + 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, source_screen_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++; + } + + // 특정 화면에서 시작하는 데이터 흐름만 조회 + if (source_screen_id) { + query += ` AND sdf.source_screen_id = $${paramIndex}`; + params.push(source_screen_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, + COALESCE( + properties->'componentConfig'->>'bindField', + properties->>'bindField', + properties->'componentConfig'->>'field', + properties->>'field' + ) as bind_field, + -- componentConfig 전체 (JavaScript에서 다양한 패턴 파싱용) + properties->'componentConfig' as component_config + 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'; + const componentConfig = row.component_config || {}; + + // 다양한 컴포넌트 타입에서 usedColumns, joinColumns 추출 + let usedColumns: string[] = []; + let joinColumns: string[] = []; + + // 1. 기본 columns 배열에서 추출 (table-list 등) + if (Array.isArray(componentConfig.columns)) { + componentConfig.columns.forEach((col: any) => { + const colName = col.columnName || col.field || col.name; + if (colName && !usedColumns.includes(colName)) { + usedColumns.push(colName); + } + if (col.isEntityJoin === true && colName && !joinColumns.includes(colName)) { + joinColumns.push(colName); + } + }); + } + + // 2. split-panel-layout의 leftPanel.columns, rightPanel.columns 추출 + if (componentKind === 'split-panel-layout') { + if (componentConfig.leftPanel?.columns && Array.isArray(componentConfig.leftPanel.columns)) { + componentConfig.leftPanel.columns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName && !usedColumns.includes(colName)) { + usedColumns.push(colName); + } + }); + } + if (componentConfig.rightPanel?.columns && Array.isArray(componentConfig.rightPanel.columns)) { + componentConfig.rightPanel.columns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName) { + // customer_mng.customer_name 같은 경우 조인 컬럼으로 처리 + if (colName.includes('.')) { + if (!joinColumns.includes(colName)) { + joinColumns.push(colName); + } + } else { + if (!usedColumns.includes(colName)) { + usedColumns.push(colName); + } + } + } + }); + } + } + + // 3. selected-items-detail-input의 additionalFields, displayColumns 추출 + if (componentKind === 'selected-items-detail-input') { + if (componentConfig.additionalFields && Array.isArray(componentConfig.additionalFields)) { + componentConfig.additionalFields.forEach((field: any) => { + const fieldName = field.name || field.field; + if (fieldName && !usedColumns.includes(fieldName)) { + usedColumns.push(fieldName); + } + }); + } + // displayColumns는 연관 테이블에서 가져오는 표시용 컬럼이므로 + // 메인 테이블의 joinColumns가 아님 (parentDataMapping에서 별도 추출됨) + // 단, 참조용으로 usedColumns에는 추가 가능 + if (componentConfig.displayColumns && Array.isArray(componentConfig.displayColumns)) { + componentConfig.displayColumns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + // displayColumns는 연관 테이블 컬럼이므로 메인 테이블 usedColumns에 추가하지 않음 + // 조인 컬럼은 parentDataMapping.targetField에서 추출됨 + }); + } + } + + 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, + bindField: row.bind_field || null, // 바인딩된 컬럼명 + usedColumns: usedColumns, // 이 컴포넌트에서 사용하는 컬럼 목록 + joinColumns: joinColumns, // 이 컴포넌트에서 조인 컬럼 목록 + }); + + // 캔버스 크기 계산 (최대 좌표 기준) + 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 }); + } +}; + +// ============================================================ +// 화면 서브 테이블 관계 조회 (조인/참조 테이블) +// ============================================================ + +// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계) +export const getScreenSubTables = 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 배열이 필요합니다." }); + } + + // 화면별 서브 테이블 그룹화 + const screenSubTables: Record; + }>; + saveTables?: Array<{ + tableName: string; + saveType: 'save' | 'edit' | 'delete' | 'transferData'; + componentType: string; + isMainTable: boolean; + }>; + }> = {}; + + // 1. 기존 방식: componentConfig에서 tableName, sourceTable, fieldMappings 추출 + const componentQuery = ` + SELECT DISTINCT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + COALESCE( + sl.properties->'componentConfig'->>'tableName', + sl.properties->'componentConfig'->>'sourceTable' + ) as sub_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->>'targetTable' as target_table, + sl.properties->'componentConfig'->'fieldMappings' as field_mappings, + sl.properties->'componentConfig'->'columns' as columns_config + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND ( + sl.properties->'componentConfig'->>'tableName' IS NOT NULL + OR sl.properties->'componentConfig'->>'sourceTable' IS NOT NULL + ) + ORDER BY sd.screen_id + `; + + const componentResult = await pool.query(componentQuery, [screenIds]); + + // fieldMappings의 한글 컬럼명을 조회하기 위한 테이블-컬럼 쌍 수집 + const columnLabelLookups: Array<{ table: string; column: string }> = []; + componentResult.rows.forEach((row: any) => { + if (row.field_mappings && Array.isArray(row.field_mappings)) { + row.field_mappings.forEach((fm: any) => { + const mainTable = row.main_table; + const subTable = row.sub_table; + if (fm.sourceField && subTable) { + columnLabelLookups.push({ table: subTable, column: fm.sourceField }); + } + if (fm.targetField && mainTable) { + columnLabelLookups.push({ table: mainTable, column: fm.targetField }); + } + }); + } + }); + + // 한글 컬럼명 조회 + const columnLabelMap = new Map(); // "table.column" -> "한글명" + if (columnLabelLookups.length > 0) { + const uniqueLookups = [...new Set(columnLabelLookups.map(l => `${l.table}|${l.column}`))]; + const conditions = uniqueLookups.map((lookup, i) => { + const [table, column] = lookup.split('|'); + return `(table_name = $${i * 2 + 1} AND column_name = $${i * 2 + 2})`; + }); + const params = uniqueLookups.flatMap(lookup => lookup.split('|')); + + if (conditions.length > 0) { + const labelQuery = ` + SELECT table_name, column_name, column_label + FROM column_labels + WHERE ${conditions.join(' OR ')} + `; + const labelResult = await pool.query(labelQuery, params); + labelResult.rows.forEach((row: any) => { + const key = `${row.table_name}.${row.column_name}`; + columnLabelMap.set(key, row.column_label || row.column_name); + }); + } + } + + componentResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const subTable = row.sub_table; + + // 메인 테이블과 동일한 경우 제외 + if (!subTable || subTable === mainTable) { + return; + } + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === subTable + ); + + if (!exists) { + // 관계 타입 추론 + let relationType = 'lookup'; + const componentType = row.component_type || ''; + if (componentType.includes('autocomplete') || componentType.includes('entity-search')) { + relationType = 'lookup'; + } else if (componentType.includes('modal-repeater') || componentType.includes('selected-items')) { + relationType = 'source'; + } else if (componentType.includes('table')) { + relationType = 'join'; + } + + // fieldMappings 파싱 (JSON 배열 또는 null) + let fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> | undefined; + + if (row.field_mappings && Array.isArray(row.field_mappings)) { + // 1. 직접 fieldMappings가 있는 경우 + fieldMappings = row.field_mappings.map((fm: any) => { + const sourceField = fm.sourceField || fm.source_field || ''; + const targetField = fm.targetField || fm.target_field || ''; + + // 한글 컬럼명 조회 (sourceField는 서브테이블 컬럼, targetField는 메인테이블 컬럼) + const sourceKey = `${subTable}.${sourceField}`; + const targetKey = `${mainTable}.${targetField}`; + + return { + sourceField, + targetField, + // sourceField(서브테이블 컬럼)의 한글명 + sourceDisplayName: columnLabelMap.get(sourceKey) || sourceField, + // targetField(메인테이블 컬럼)의 한글명 + targetDisplayName: columnLabelMap.get(targetKey) || targetField, + }; + }).filter((fm: any) => fm.sourceField || fm.targetField); + } else if (row.columns_config && Array.isArray(row.columns_config)) { + // 2. columns_config.mapping에서 추출 (item_info 같은 경우) + // mapping.type === 'source'인 경우: sourceField(서브테이블) → field(메인테이블) + fieldMappings = []; + row.columns_config.forEach((col: any) => { + if (col.mapping && col.mapping.type === 'source' && col.mapping.sourceField) { + fieldMappings!.push({ + sourceField: col.field || '', // 메인 테이블 컬럼 + targetField: col.mapping.sourceField || '', // 서브 테이블 컬럼 + sourceDisplayName: col.label || col.field || '', // 한글 라벨 + targetDisplayName: col.mapping.sourceField || '', // 서브 테이블은 영문만 + }); + } + }); + if (fieldMappings.length === 0) { + fieldMappings = undefined; + } + } + + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: relationType, + fieldMappings: fieldMappings, + }); + } + }); + + // 2. 추가 방식: 화면에서 사용하는 컬럼 중 column_labels.reference_table이 설정된 경우 + // 화면의 usedColumns/joinColumns에서 reference_table 조회 + const referenceQuery = ` + WITH screen_used_columns AS ( + -- 화면별 사용 컬럼 추출 (componentConfig.columns에서) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + jsonb_array_elements_text( + COALESCE( + sl.properties->'componentConfig'->'columns', + '[]'::jsonb + ) + )::jsonb->>'columnName' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'columns' IS NOT NULL + AND jsonb_array_length(sl.properties->'componentConfig'->'columns') > 0 + + UNION + + -- bindField도 포함 + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + COALESCE( + sl.properties->'componentConfig'->>'bindField', + sl.properties->>'bindField', + sl.properties->'componentConfig'->>'field', + sl.properties->>'field' + ) as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND ( + sl.properties->'componentConfig'->>'bindField' IS NOT NULL + OR sl.properties->>'bindField' IS NOT NULL + OR sl.properties->'componentConfig'->>'field' IS NOT NULL + OR sl.properties->>'field' IS NOT NULL + ) + + UNION + + -- valueField 추출 (entity-search-input, autocomplete-search-input 등에서 사용) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'valueField' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'valueField' IS NOT NULL + + UNION + + -- parentFieldId 추출 (캐스케이딩 관계에서 사용) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'parentFieldId' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'parentFieldId' IS NOT NULL + + UNION + + -- cascadingParentField 추출 (캐스케이딩 부모 필드) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'cascadingParentField' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'cascadingParentField' IS NOT NULL + + UNION + + -- controlField 추출 (conditional-container에서 사용) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->>'controlField' as column_name + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->>'controlField' IS NOT NULL + ) + SELECT DISTINCT + suc.screen_id, + suc.screen_name, + suc.main_table, + suc.column_name, + cl.column_label as source_display_name, + cl.reference_table, + cl.reference_column, + ref_cl.column_label as target_display_name + FROM screen_used_columns suc + JOIN column_labels cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name + LEFT JOIN column_labels ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column + WHERE cl.reference_table IS NOT NULL + AND cl.reference_table != '' + AND cl.reference_table != suc.main_table + AND cl.input_type = 'entity' + ORDER BY suc.screen_id + `; + + const referenceResult = await pool.query(referenceQuery, [screenIds]); + + logger.info("column_labels reference_table 조회 결과", { + screenIds, + referenceCount: referenceResult.rows.length, + references: referenceResult.rows.map((r: any) => ({ + screenId: r.screen_id, + column: r.column_name, + refTable: r.reference_table + })) + }); + + referenceResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const referenceTable = row.reference_table; + + if (!referenceTable || referenceTable === mainTable) { + return; + } + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const exists = screenSubTables[screenId].subTables.some( + (st) => st.tableName === referenceTable + ); + + if (!exists) { + screenSubTables[screenId].subTables.push({ + tableName: referenceTable, + componentType: 'column_reference', + relationType: 'reference', + fieldMappings: [{ + sourceField: row.column_name, + targetField: row.reference_column || 'id', + sourceDisplayName: row.source_display_name || row.column_name, + targetDisplayName: row.target_display_name || row.reference_column || 'id', + }], + }); + } else { + // 이미 존재하면 fieldMappings에 추가 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === referenceTable + ); + if (existingSubTable && existingSubTable.fieldMappings) { + const mappingExists = existingSubTable.fieldMappings.some( + (fm) => fm.sourceField === row.column_name + ); + if (!mappingExists) { + existingSubTable.fieldMappings.push({ + sourceField: row.column_name, + targetField: row.reference_column || 'id', + sourceDisplayName: row.source_display_name || row.column_name, + targetDisplayName: row.target_display_name || row.reference_column || 'id', + }); + } + } + } + }); + + // 3. parentDataMapping 파싱 (selected-items-detail-input 등에서 사용) + const parentMappingQuery = ` + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->'parentDataMapping' as parent_data_mapping + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'parentDataMapping' IS NOT NULL + `; + + const parentMappingResult = await pool.query(parentMappingQuery, [screenIds]); + + parentMappingResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const componentType = row.component_type || 'parentDataMapping'; + const parentDataMapping = row.parent_data_mapping; + + if (!Array.isArray(parentDataMapping)) return; + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + parentDataMapping.forEach((mapping: any) => { + const sourceTable = mapping.sourceTable; + if (!sourceTable || sourceTable === mainTable) return; + + // 중복 체크 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === sourceTable + ); + + const newMapping = { + sourceTable: sourceTable, // 연관 테이블 정보 추가 + sourceField: mapping.sourceField || '', + targetField: mapping.targetField || '', + sourceDisplayName: mapping.sourceField || '', + targetDisplayName: mapping.targetField || '', + }; + + if (existingSubTable) { + // 이미 존재하면 fieldMappings에 추가 + if (!existingSubTable.fieldMappings) { + existingSubTable.fieldMappings = []; + } + const mappingExists = existingSubTable.fieldMappings.some( + (fm: any) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField + ); + if (!mappingExists) { + existingSubTable.fieldMappings.push(newMapping); + } + } else { + screenSubTables[screenId].subTables.push({ + tableName: sourceTable, + componentType: componentType, + relationType: 'parentMapping', + fieldMappings: [newMapping], + }); + } + }); + }); + + logger.info("parentDataMapping 파싱 완료", { + screenIds, + parentMappingCount: parentMappingResult.rows.length + }); + + // 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용) + const rightPanelQuery = ` + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, + sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, + sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + `; + + const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); + + // rightPanel.columns에서 참조되는 외부 테이블 수집 (예: customer_mng.customer_name → customer_mng) + const rightPanelJoinedTables: Map> = new Map(); // screenId_tableName → Set<참조테이블> + + rightPanelResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const rightPanelTable = row.right_panel_table; + const rightPanelColumns = row.right_panel_columns; + + if (rightPanelColumns && Array.isArray(rightPanelColumns)) { + rightPanelColumns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName && colName.includes('.')) { + const refTable = colName.split('.')[0]; + const key = `${screenId}_${rightPanelTable}`; + if (!rightPanelJoinedTables.has(key)) { + rightPanelJoinedTables.set(key, new Set()); + } + rightPanelJoinedTables.get(key)!.add(refTable); + } + }); + } + }); + + rightPanelResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const componentType = row.component_type || 'split-panel-layout'; + const relation = row.right_panel_relation; + const rightPanelTable = row.right_panel_table; + + // relation 객체에서 테이블 및 필드 매핑 추출 + const subTable = rightPanelTable || relation?.targetTable || relation?.tableName; + if (!subTable || subTable === mainTable) return; + + // rightPanel.columns에서 참조하는 외부 테이블 목록 + const key = `${screenId}_${subTable}`; + const joinedTables = rightPanelJoinedTables.get(key) ? Array.from(rightPanelJoinedTables.get(key)!) : []; + + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + }; + } + + // 중복 체크 + const existingSubTable = screenSubTables[screenId].subTables.find( + (st) => st.tableName === subTable + ); + + // relation에서 필드 매핑 추출 + const fieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = []; + + if (relation?.sourceField && relation?.targetField) { + fieldMappings.push({ + sourceField: relation.sourceField, + targetField: relation.targetField, + sourceDisplayName: relation.sourceField, + targetDisplayName: relation.targetField, + }); + } + + // fieldMappings 배열이 있는 경우 + if (relation?.fieldMappings && Array.isArray(relation.fieldMappings)) { + relation.fieldMappings.forEach((fm: any) => { + fieldMappings.push({ + sourceField: fm.sourceField || fm.source_field || '', + targetField: fm.targetField || fm.target_field || '', + sourceDisplayName: fm.sourceField || fm.source_field || '', + targetDisplayName: fm.targetField || fm.target_field || '', + }); + }); + } + + if (existingSubTable) { + // 이미 존재하면 fieldMappings에 추가 + if (!existingSubTable.fieldMappings) { + existingSubTable.fieldMappings = []; + } + fieldMappings.forEach((newMapping) => { + const mappingExists = existingSubTable.fieldMappings!.some( + (fm) => fm.sourceField === newMapping.sourceField && fm.targetField === newMapping.targetField + ); + if (!mappingExists) { + existingSubTable.fieldMappings!.push(newMapping); + } + }); + // 추가 정보도 업데이트 + if (relation?.type) { + (existingSubTable as any).originalRelationType = relation.type; + } + if (relation?.foreignKey) { + (existingSubTable as any).foreignKey = relation.foreignKey; + } + if (relation?.leftColumn) { + (existingSubTable as any).leftColumn = relation.leftColumn; + } + } else { + screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: 'rightPanelRelation', + // 관계 유형 추론을 위한 추가 정보 + originalRelationType: relation?.type || 'join', // 원본 relation.type ("join" | "detail") + foreignKey: relation?.foreignKey, // 디테일 테이블의 FK 컬럼 + leftColumn: relation?.leftColumn, // 마스터 테이블의 선택 기준 컬럼 + joinedTables: joinedTables.length > 0 ? joinedTables : undefined, // rightPanel.columns에서 참조하는 외부 테이블들 + fieldMappings: fieldMappings.length > 0 ? fieldMappings : undefined, + } as any); + } + }); + + logger.info("rightPanel.relation 파싱 완료", { + screenIds, + rightPanelCount: rightPanelResult.rows.length + }); + + // 5. joinedTables에 대한 FK 컬럼을 column_labels에서 조회 + // rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기 + const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = []; + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.joinedTables && Array.isArray(subTable.joinedTables)) { + subTable.joinedTables.forEach((refTable: string) => { + joinedTableFKLookups.push({ subTableName: subTable.tableName, refTable }); + }); + } + }); + }); + + // column_labels에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기) + const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들] + if (joinedTableFKLookups.length > 0) { + const uniqueLookups = joinedTableFKLookups.filter((item, index, self) => + index === self.findIndex((t) => t.subTableName === item.subTableName && t.refTable === item.refTable) + ); + + // 각 subTable에 대해 reference_table이 일치하는 컬럼 조회 + const subTableNames = [...new Set(uniqueLookups.map(l => l.subTableName))]; + const refTableNames = [...new Set(uniqueLookups.map(l => l.refTable))]; + + const fkQuery = ` + SELECT + cl.table_name, + cl.column_name, + cl.column_label, + cl.reference_table, + cl.reference_column, + tl.table_label as reference_table_label + FROM column_labels cl + LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name + WHERE cl.table_name = ANY($1) + AND cl.reference_table = ANY($2) + `; + + const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]); + + // 참조 정보 포함 객체 배열로 저장 (한글명 포함) + const joinColumnRefsByTable: Record> = {}; + + fkResult.rows.forEach((row: any) => { + if (!joinColumnRefsByTable[row.table_name]) { + joinColumnRefsByTable[row.table_name] = []; + } + // 중복 체크 + const exists = joinColumnRefsByTable[row.table_name].some( + (ref) => ref.column === row.column_name && ref.refTable === row.reference_table + ); + if (!exists) { + joinColumnRefsByTable[row.table_name].push({ + column: row.column_name, + columnLabel: row.column_label || row.column_name, // 컬럼 한글명 (없으면 영문명) + refTable: row.reference_table, + refTableLabel: row.reference_table_label || row.reference_table, // 참조 테이블 한글명 (없으면 영문명) + refColumn: row.reference_column || 'id', + }); + } + }); + + // subTables에 joinColumns (문자열 배열) 및 joinColumnRefs (참조 정보 배열) 추가 + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + const refs = joinColumnRefsByTable[subTable.tableName]; + if (refs) { + (subTable as any).joinColumns = refs.map(r => r.column); + (subTable as any).joinColumnRefs = refs; + } + }); + }); + + logger.info("rightPanel joinedTables FK 조회 완료", { + lookupCount: uniqueLookups.length, + resultCount: fkResult.rows.length, + joinColumnsByTable + }); + } + + // 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용 + // 모든 테이블/컬럼 조합을 수집 + const columnLookups: Array<{ tableName: string; columnName: string }> = []; + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.fieldMappings) { + subTable.fieldMappings.forEach((mapping: any) => { + // sourceTable + sourceField (연관 테이블의 컬럼) + if (mapping.sourceTable && mapping.sourceField) { + columnLookups.push({ tableName: mapping.sourceTable, columnName: mapping.sourceField }); + } + // mainTable + targetField (메인 테이블의 컬럼) + if (screenData.mainTable && mapping.targetField) { + columnLookups.push({ tableName: screenData.mainTable, columnName: mapping.targetField }); + } + }); + } + }); + }); + + // 중복 제거 + const uniqueColumnLookups = columnLookups.filter((item, index, self) => + index === self.findIndex((t) => t.tableName === item.tableName && t.columnName === item.columnName) + ); + + // column_labels에서 한글명 조회 + const columnLabelsMap: { [key: string]: string } = {}; + if (uniqueColumnLookups.length > 0) { + const columnLabelsQuery = ` + SELECT + table_name, + column_name, + column_label + FROM column_labels + WHERE (table_name, column_name) IN ( + ${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')} + ) + `; + const columnLabelsParams = uniqueColumnLookups.flatMap(item => [item.tableName, item.columnName]); + + try { + const columnLabelsResult = await pool.query(columnLabelsQuery, columnLabelsParams); + columnLabelsResult.rows.forEach((row: any) => { + const key = `${row.table_name}.${row.column_name}`; + columnLabelsMap[key] = row.column_label; + }); + logger.info("column_labels 한글명 조회 완료", { count: columnLabelsResult.rows.length }); + } catch (error: any) { + logger.warn("column_labels 한글명 조회 실패 (무시하고 계속 진행):", error.message); + } + } + + // 각 fieldMappings에 한글명 적용 + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.fieldMappings) { + subTable.fieldMappings.forEach((mapping: any) => { + // sourceDisplayName: 연관 테이블의 컬럼 한글명 + if (mapping.sourceTable && mapping.sourceField) { + const sourceKey = `${mapping.sourceTable}.${mapping.sourceField}`; + if (columnLabelsMap[sourceKey]) { + mapping.sourceDisplayName = columnLabelsMap[sourceKey]; + } + } + // targetDisplayName: 메인 테이블의 컬럼 한글명 + if (screenData.mainTable && mapping.targetField) { + const targetKey = `${screenData.mainTable}.${mapping.targetField}`; + if (columnLabelsMap[targetKey]) { + mapping.targetDisplayName = columnLabelsMap[targetKey]; + } + } + }); + } + }); + }); + + // ============================================================ + // 저장 테이블 정보 추출 + // ============================================================ + // 제외 조건: + // 1. table-list + 체크박스 활성화 + openModalWithData 버튼이 있는 화면 + // → 선택 후 다음 화면으로 넘기는 패턴 (실제 DB 저장 아님) + const saveTableQuery = ` + SELECT DISTINCT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + sl.properties->'componentConfig'->'action'->>'type' as action_type, + sl.properties->>'componentType' as component_type, + sl.properties->'componentConfig'->>'targetTable' as target_table, + sl.properties->'componentConfig'->'action'->'dataTransfer'->>'targetTable' as transfer_target_table + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'action'->>'type' = 'save' + AND sl.properties->'componentConfig'->'action'->>'targetScreenId' IS NULL + -- 제외: table-list + 체크박스가 있는 화면 + AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_list + WHERE sl_list.screen_id = sd.screen_id + AND sl_list.properties->>'componentType' = 'table-list' + AND (sl_list.properties->'componentConfig'->'checkbox'->>'enabled')::boolean = true + ) + -- 제외: openModalWithData 버튼이 있는 화면 (선택 → 다음 화면 패턴) + AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_modal + WHERE sl_modal.screen_id = sd.screen_id + AND sl_modal.properties->'componentConfig'->'action'->>'type' = 'openModalWithData' + ) + ORDER BY sd.screen_id + `; + + const saveTableResult = await pool.query(saveTableQuery, [screenIds]); + + saveTableResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const mainTable = row.main_table; + const actionType = row.action_type as 'save' | 'edit' | 'delete' | 'transferData'; + const componentType = row.component_type || 'component'; + const targetTable = row.target_table || row.transfer_target_table || mainTable; + + // 화면 정보가 없으면 초기화 + if (!screenSubTables[screenId]) { + screenSubTables[screenId] = { + screenId, + screenName: row.screen_name, + mainTable: mainTable || '', + subTables: [], + saveTables: [], + }; + } + + // saveTables 배열 초기화 + if (!screenSubTables[screenId].saveTables) { + screenSubTables[screenId].saveTables = []; + } + + // 중복 체크 + const existingSaveTable = screenSubTables[screenId].saveTables!.find( + (st) => st.tableName === targetTable && st.saveType === actionType + ); + + if (!existingSaveTable && targetTable) { + screenSubTables[screenId].saveTables!.push({ + tableName: targetTable, + saveType: actionType, + componentType, + isMainTable: targetTable === mainTable, + }); + } + }); + + logger.info("화면 서브 테이블 정보 조회 완료", { + screenIds, + resultCount: Object.keys(screenSubTables).length, + details: Object.values(screenSubTables).map(s => ({ + screenId: s.screenId, + mainTable: s.mainTable, + subTables: s.subTables.map(st => st.tableName), + saveTables: s.saveTables?.map(st => st.tableName) || [] + })) + }); + + res.json({ + success: true, + data: screenSubTables, + }); + } catch (error: any) { + logger.error("화면 서브 테이블 정보 조회 실패:", error); + res.status(500).json({ success: false, message: "화면 서브 테이블 정보 조회에 실패했습니다.", error: error.message }); + } +}; + diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 65cd5f4c..e8c5a1bb 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -775,18 +775,25 @@ export async function getTableData( const userField = autoFilter?.userField || "companyCode"; const userValue = (req.user as any)[userField]; - // 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능 - if (userValue && userValue !== "*") { - enhancedSearch[filterColumn] = userValue; + // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) + let finalCompanyCode = userValue; + if (autoFilter?.companyCodeOverride && userValue === "*") { + // 최고 관리자만 다른 회사 코드로 오버라이드 가능 + finalCompanyCode = autoFilter.companyCodeOverride; + logger.info("🔓 최고 관리자 회사 코드 오버라이드:", { + originalCompanyCode: userValue, + overrideCompanyCode: autoFilter.companyCodeOverride, + tableName, + }); + } + + if (finalCompanyCode) { + enhancedSearch[filterColumn] = finalCompanyCode; logger.info("🔍 현재 사용자 필터 적용:", { filterColumn, userField, - userValue, - tableName, - }); - } else if (userValue === "*") { - logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", { + userValue: finalCompanyCode, tableName, }); } else { @@ -798,7 +805,10 @@ export async function getTableData( } // 🆕 최종 검색 조건 로그 - logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch)); + logger.info( + `🔍 최종 검색 조건 (enhancedSearch):`, + JSON.stringify(enhancedSearch) + ); // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { @@ -883,7 +893,10 @@ export async function addTableData( const companyCode = req.user?.companyCode; if (companyCode && !data.company_code) { // 테이블에 company_code 컬럼이 있는지 확인 - const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); + const hasCompanyCodeColumn = await tableManagementService.hasColumn( + tableName, + "company_code" + ); if (hasCompanyCodeColumn) { data.company_code = companyCode; logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`); @@ -893,7 +906,10 @@ export async function addTableData( // 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우) const userId = req.user?.userId; if (userId && !data.writer) { - const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer"); + const hasWriterColumn = await tableManagementService.hasColumn( + tableName, + "writer" + ); if (hasWriterColumn) { data.writer = userId; logger.info(`writer 자동 추가 - ${userId}`); @@ -911,11 +927,13 @@ export async function addTableData( savedColumns?: string[]; }> = { success: true, - message: result.skippedColumns.length > 0 - ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` - : "테이블 데이터를 성공적으로 추가했습니다.", + message: + result.skippedColumns.length > 0 + ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` + : "테이블 데이터를 성공적으로 추가했습니다.", data: { - skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined, + skippedColumns: + result.skippedColumns.length > 0 ? result.skippedColumns : undefined, savedColumns: result.savedColumns, }, }; @@ -1645,10 +1663,10 @@ export async function toggleLogTable( /** * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) - * + * * @route GET /api/table-management/menu/:menuObjid/category-columns * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 - * + * * 예시: * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) @@ -1661,7 +1679,10 @@ export async function getCategoryColumnsByMenu( const { menuObjid } = req.params; const companyCode = req.user?.companyCode; - logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode }); + logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { + menuObjid, + companyCode, + }); if (!menuObjid) { res.status(400).json({ @@ -1687,8 +1708,11 @@ export async function getCategoryColumnsByMenu( if (mappingTableExists) { // 🆕 category_column_mapping을 사용한 계층 구조 기반 조회 - logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode }); - + logger.info( + "🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", + { menuObjid, companyCode } + ); + // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) const ancestorMenuQuery = ` WITH RECURSIVE menu_hierarchy AS ( @@ -1710,17 +1734,21 @@ export async function getCategoryColumnsByMenu( ARRAY_AGG(menu_name_kor) as menu_names FROM menu_hierarchy `; - - const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); - const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; + + const ancestorMenuResult = await pool.query(ancestorMenuQuery, [ + parseInt(menuObjid), + ]); + const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [ + parseInt(menuObjid), + ]; const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || []; - - logger.info("✅ 상위 메뉴 계층 조회 완료", { - ancestorMenuObjids, + + logger.info("✅ 상위 메뉴 계층 조회 완료", { + ancestorMenuObjids, ancestorMenuNames, - hierarchyDepth: ancestorMenuObjids.length + hierarchyDepth: ancestorMenuObjids.length, }); - + // 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거) const columnsQuery = ` SELECT DISTINCT @@ -1750,20 +1778,31 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ccm.logical_column_name `; - - columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]); - logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", { - rowCount: columnsResult.rows.length, - columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`) - }); + + columnsResult = await pool.query(columnsQuery, [ + companyCode, + ancestorMenuObjids, + ]); + logger.info( + "✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", + { + rowCount: columnsResult.rows.length, + columns: columnsResult.rows.map( + (r: any) => `${r.tableName}.${r.columnName}` + ), + } + ); } else { // 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회 - logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); - + logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { + menuObjid, + companyCode, + }); + // 형제 메뉴 조회 const { getSiblingMenuObjids } = await import("../services/menuService"); const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); - + // 형제 메뉴들이 사용하는 테이블 조회 const tablesQuery = ` SELECT DISTINCT sd.table_name @@ -1773,11 +1812,17 @@ export async function getCategoryColumnsByMenu( AND sma.company_code = $2 AND sd.table_name IS NOT NULL `; - - const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + + const tablesResult = await pool.query(tablesQuery, [ + siblingObjids, + companyCode, + ]); const tableNames = tablesResult.rows.map((row: any) => row.table_name); - - logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length }); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { + tableNames, + count: tableNames.length, + }); if (tableNames.length === 0) { res.json({ @@ -1787,7 +1832,7 @@ export async function getCategoryColumnsByMenu( }); return; } - + const columnsQuery = ` SELECT ttc.table_name AS "tableName", @@ -1812,13 +1857,15 @@ export async function getCategoryColumnsByMenu( AND ttc.input_type = 'category' ORDER BY ttc.table_name, ttc.column_name `; - + columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); - logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length }); + logger.info("✅ 레거시 방식 조회 완료", { + rowCount: columnsResult.rows.length, + }); } - - logger.info("✅ 카테고리 컬럼 조회 완료", { - columnCount: columnsResult.rows.length + + logger.info("✅ 카테고리 컬럼 조회 완료", { + columnCount: columnsResult.rows.length, }); res.json({ @@ -1843,9 +1890,9 @@ export async function getCategoryColumnsByMenu( /** * 범용 다중 테이블 저장 API - * + * * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. - * + * * 요청 본문: * { * mainTable: { tableName: string, primaryKeyColumn: string }, @@ -1915,23 +1962,29 @@ export async function multiTableSave( } let mainResult: any; - + if (isUpdate && pkValue) { // UPDATE const updateColumns = Object.keys(mainData) - .filter(col => col !== pkColumn) + .filter((col) => col !== pkColumn) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); const updateValues = Object.keys(mainData) - .filter(col => col !== pkColumn) - .map(col => mainData[col]); - + .filter((col) => col !== pkColumn) + .map((col) => mainData[col]); + // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query(` + const hasUpdatedAt = await client.query( + ` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, [mainTableName]); - const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + `, + [mainTableName] + ); + const updatedAtClause = + hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 + ? ", updated_at = NOW()" + : ""; const updateQuery = ` UPDATE "${mainTableName}" @@ -1940,29 +1993,43 @@ export async function multiTableSave( ${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""} RETURNING * `; - - const updateParams = companyCode !== "*" - ? [...updateValues, pkValue, companyCode] - : [...updateValues, pkValue]; - - logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length }); + + const updateParams = + companyCode !== "*" + ? [...updateValues, pkValue, companyCode] + : [...updateValues, pkValue]; + + logger.info("메인 테이블 UPDATE:", { + query: updateQuery, + paramsCount: updateParams.length, + }); mainResult = await client.query(updateQuery, updateParams); } else { // INSERT - const columns = Object.keys(mainData).map(col => `"${col}"`).join(", "); - const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", "); + const columns = Object.keys(mainData) + .map((col) => `"${col}"`) + .join(", "); + const placeholders = Object.keys(mainData) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const values = Object.values(mainData); // updated_at 컬럼 존재 여부 확인 - const hasUpdatedAt = await client.query(` + const hasUpdatedAt = await client.query( + ` SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'updated_at' - `, [mainTableName]); - const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + `, + [mainTableName] + ); + const updatedAtClause = + hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 + ? ", updated_at = NOW()" + : ""; const updateSetClause = Object.keys(mainData) - .filter(col => col !== pkColumn) - .map(col => `"${col}" = EXCLUDED."${col}"`) + .filter((col) => col !== pkColumn) + .map((col) => `"${col}" = EXCLUDED."${col}"`) .join(", "); const insertQuery = ` @@ -1973,7 +2040,10 @@ export async function multiTableSave( RETURNING * `; - logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length }); + logger.info("메인 테이블 INSERT/UPSERT:", { + query: insertQuery, + paramsCount: values.length, + }); mainResult = await client.query(insertQuery, values); } @@ -1992,12 +2062,15 @@ export async function multiTableSave( const { tableName, linkColumn, items, options } = subTableConfig; // saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함 - const hasSaveMainAsFirst = options?.saveMainAsFirst && - options?.mainFieldMappings && - options.mainFieldMappings.length > 0; - + const hasSaveMainAsFirst = + options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0; + if (!tableName || (!items?.length && !hasSaveMainAsFirst)) { - logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`); + logger.info( + `서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})` + ); continue; } @@ -2010,15 +2083,20 @@ export async function multiTableSave( // 기존 데이터 삭제 옵션 if (options?.deleteExistingBefore && linkColumn?.subColumn) { - const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn - ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` - : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; - - const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn - ? [savedPkValue, options.subMarkerValue ?? false] - : [savedPkValue]; + const deleteQuery = + options?.deleteOnlySubItems && options?.mainMarkerColumn + ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` + : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; - logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); + const deleteParams = + options?.deleteOnlySubItems && options?.mainMarkerColumn + ? [savedPkValue, options.subMarkerValue ?? false] + : [savedPkValue]; + + logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { + deleteQuery, + deleteParams, + }); await client.query(deleteQuery, deleteParams); } @@ -2031,7 +2109,12 @@ export async function multiTableSave( linkColumn, mainDataKeys: Object.keys(mainData), }); - if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) { + if ( + options?.saveMainAsFirst && + options?.mainFieldMappings && + options.mainFieldMappings.length > 0 && + linkColumn?.subColumn + ) { const mainSubItem: Record = { [linkColumn.subColumn]: savedPkValue, }; @@ -2045,7 +2128,8 @@ export async function multiTableSave( // 메인 마커 설정 if (options.mainMarkerColumn) { - mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; + mainSubItem[options.mainMarkerColumn] = + options.mainMarkerValue ?? true; } // company_code 추가 @@ -2068,20 +2152,30 @@ export async function multiTableSave( if (companyCode !== "*") { checkParams.push(companyCode); } - + const existingResult = await client.query(checkQuery, checkParams); - + if (existingResult.rows.length > 0) { // UPDATE const updateColumns = Object.keys(mainSubItem) - .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") + .filter( + (col) => + col !== linkColumn.subColumn && + col !== options.mainMarkerColumn && + col !== "company_code" + ) .map((col, idx) => `"${col}" = $${idx + 1}`) .join(", "); - + const updateValues = Object.keys(mainSubItem) - .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") - .map(col => mainSubItem[col]); - + .filter( + (col) => + col !== linkColumn.subColumn && + col !== options.mainMarkerColumn && + col !== "company_code" + ) + .map((col) => mainSubItem[col]); + if (updateColumns) { const updateQuery = ` UPDATE "${tableName}" @@ -2100,14 +2194,26 @@ export async function multiTableSave( } const updateResult = await client.query(updateQuery, updateParams); - subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: updateResult.rows[0], + }); } else { - subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: existingResult.rows[0], + }); } } else { // INSERT - const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); - const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); + const mainSubColumns = Object.keys(mainSubItem) + .map((col) => `"${col}"`) + .join(", "); + const mainSubPlaceholders = Object.keys(mainSubItem) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const mainSubValues = Object.values(mainSubItem); const insertQuery = ` @@ -2117,7 +2223,11 @@ export async function multiTableSave( `; const insertResult = await client.query(insertQuery, mainSubValues); - subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] }); + subTableResults.push({ + tableName, + type: "main", + data: insertResult.rows[0], + }); } } @@ -2133,8 +2243,12 @@ export async function multiTableSave( item.company_code = companyCode; } - const subColumns = Object.keys(item).map(col => `"${col}"`).join(", "); - const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", "); + const subColumns = Object.keys(item) + .map((col) => `"${col}"`) + .join(", "); + const subPlaceholders = Object.keys(item) + .map((_, idx) => `$${idx + 1}`) + .join(", "); const subValues = Object.values(item); const subInsertQuery = ` @@ -2143,9 +2257,16 @@ export async function multiTableSave( RETURNING * `; - logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length }); + logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { + subInsertQuery, + subValuesCount: subValues.length, + }); const subResult = await client.query(subInsertQuery, subValues); - subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] }); + subTableResults.push({ + tableName, + type: "sub", + data: subResult.rows[0], + }); } logger.info(`서브 테이블 ${tableName} 저장 완료`); @@ -2188,7 +2309,7 @@ export async function multiTableSave( /** * 두 테이블 간의 엔티티 관계 자동 감지 * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy - * + * * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. */ @@ -2199,7 +2320,9 @@ export async function getTableEntityRelations( try { const { leftTable, rightTable } = req.query; - logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`); + logger.info( + `=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===` + ); if (!leftTable || !rightTable) { const response: ApiResponse = { @@ -2248,4 +2371,3 @@ export async function getTableEntityRelations( res.status(500).json(response); } } - diff --git a/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts new file mode 100644 index 00000000..d4980fe8 --- /dev/null +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -0,0 +1,94 @@ +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, + // 화면 서브 테이블 관계 + getScreenSubTables, +} 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); + +// ============================================================ +// 화면 서브 테이블 관계 (조인/참조 테이블) +// ============================================================ +router.post("/sub-tables/batch", getScreenSubTables); + +export default router; + + diff --git a/docs/화면관계_시각화_개선_보고서.md b/docs/화면관계_시각화_개선_보고서.md new file mode 100644 index 00000000..27946afa --- /dev/null +++ b/docs/화면관계_시각화_개선_보고서.md @@ -0,0 +1,1745 @@ +# 화면 관계 시각화 기능 개선 보고서 + +## 개요 + +화면 그룹 관리에서 ReactFlow를 사용한 화면-테이블 관계 시각화 기능의 범용성 및 정확성 개선 작업을 수행했습니다. + +--- + +## 수정된 파일 목록 + +| 파일 경로 | 역할 | +|----------|------| +| `backend-node/src/controllers/screenGroupController.ts` | 화면 서브테이블 정보 API | +| `frontend/components/screen/ScreenRelationFlow.tsx` | ReactFlow 시각화 컴포넌트 | +| `frontend/lib/api/screenGroup.ts` | API 인터페이스 정의 | + +--- + +## 사용된 데이터베이스 테이블 + +### 화면 정의 관련 + +| 테이블명 | 용도 | 주요 컬럼 | +|----------|------|----------| +| `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` | +| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 | `screen_id`, `properties` (JSONB - componentConfig 포함) | +| `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` | +| `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` | + +### 메타데이터 관련 + +| 테이블명 | 용도 | 주요 컬럼 | +|----------|------|----------| +| `column_labels` | 컬럼 한글명/참조 정보 | `table_name`, `column_name`, `column_label`, `reference_table`, `reference_column` | +| `table_info` | 테이블 메타 정보 | `table_name`, `table_label`, `column_count` | + +### 조인 정보 추출 소스 + +#### 1. column_labels 테이블 (테이블 관리에서 설정) + +```sql +SELECT + cl.table_name, + cl.column_name, + cl.reference_table, -- 참조 테이블 + cl.reference_column, -- 참조 컬럼 + cl.column_label -- 한글명 +FROM column_labels cl +WHERE cl.reference_table IS NOT NULL +``` + +#### 2. screen_layouts.properties (화면 컴포넌트에서 설정) + +```sql +-- parentDataMapping 추출 +SELECT + sd.screen_id, + sl.properties->'componentConfig'->'parentDataMapping' as parent_data_mapping +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'parentDataMapping' IS NOT NULL + +-- rightPanel.relation 추출 +SELECT + sd.screen_id, + sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + +-- fieldMappings 추출 +SELECT + sd.screen_id, + sl.properties->'componentConfig'->'fieldMappings' as field_mappings +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'fieldMappings' IS NOT NULL +``` + +### 테이블 관계도 + +``` +screen_groups (그룹) + │ + ├─── screen_group_mappings (매핑) + │ │ + │ └─── screen_definitions (화면) + │ │ + │ └─── screen_layouts (레이아웃/컴포넌트) + │ │ + │ └─── properties.componentConfig + │ ├── fieldMappings + │ ├── parentDataMapping + │ ├── columns.mapping + │ └── rightPanel.relation + │ + └─── column_labels (컬럼 메타데이터) + │ + ├── reference_table (참조 테이블) + └── column_label (한글명) +``` + +--- + +## 핵심 문제 및 해결 + +### 1. 조인 컬럼 식별 오류 + +#### 문제 +- `customer_item_mapping` 테이블에서 `customer_id`가 조인 컬럼으로 표시되지 않음 +- `customer_mng`가 `relationType: source`로 분류되어 `sourceField`가 잘못 사용됨 + +#### 원인 +```typescript +// 기존 잘못된 로직 +if (subTable.relationType === 'source') { + // sourceField를 메인테이블 컬럼으로 사용 (잘못됨) + joinColumns.push(mapping.sourceField); +} +``` + +#### 해결 +```typescript +// 수정된 범용 로직 +if (subTable.relationType === 'source' && mapping.sourceTable) { + // sourceTable이 있으면 parentDataMapping과 유사 + // targetField가 메인테이블 컬럼 + joinColumns.push(mapping.targetField); +} else if (subTable.relationType === 'source') { + // 일반 source 타입 + joinColumns.push(mapping.sourceField); +} +``` + +--- + +### 2. displayColumns 잘못된 처리 + +#### 문제 +- `selected-items-detail-input` 컴포넌트의 `displayColumns`가 메인테이블 `joinColumns`에 추가됨 +- `customer_name`, `customer_code` 등이 조인 컬럼으로 잘못 표시됨 + +#### 원인 +```typescript +// 기존 잘못된 로직 +if (componentConfig.displayColumns) { + componentConfig.displayColumns.forEach((col) => { + joinColumns.push(col.name); // 연관 테이블 컬럼을 메인테이블에 추가 (잘못됨) + }); +} +``` + +#### 해결 +```typescript +// 수정된 로직 - displayColumns는 연관 테이블 컬럼이므로 제외 +// 조인 컬럼은 parentDataMapping.targetField에서 별도 추출됨 +if (componentConfig.displayColumns) { + // 메인 테이블 joinColumns에 추가하지 않음 +} +``` + +--- + +### 3. 조인 정보 한글명 미표시 + +#### 문제 +- 조인 컬럼 옆에 `← customer_code` (영문)로 표시됨 +- `← 거래처 코드` (한글)로 표시되어야 함 + +#### 해결 +백엔드에서 `column_labels` 테이블 조회하여 한글명 적용: + +```typescript +// 모든 테이블/컬럼 조합 수집 +const columnLookups = []; +screenSubTables.forEach((screenData) => { + screenData.subTables.forEach((subTable) => { + subTable.fieldMappings?.forEach((mapping) => { + if (mapping.sourceTable && mapping.sourceField) { + columnLookups.push({ tableName: mapping.sourceTable, columnName: mapping.sourceField }); + } + if (screenData.mainTable && mapping.targetField) { + columnLookups.push({ tableName: screenData.mainTable, columnName: mapping.targetField }); + } + }); + }); +}); + +// column_labels에서 한글명 조회 +const columnLabelsQuery = ` + SELECT table_name, column_name, column_label + FROM column_labels + WHERE (table_name, column_name) IN (...) +`; + +// 각 fieldMapping에 한글명 적용 +mapping.sourceDisplayName = columnLabelsMap[`${sourceTable}.${sourceField}`]; +mapping.targetDisplayName = columnLabelsMap[`${mainTable}.${targetField}`]; +``` + +--- + +### 4. 연결 선 라벨 제거 및 단일 로직화 + +#### 문제 +- 테이블 간 연결 선에 `customer_code → customer_id` 라벨이 표시됨 +- 조인 정보가 테이블 노드 내부에 이미 표시되므로 중복 +- 메인-메인 테이블 조인 로직이 2개 존재 (주황색, 초록색) + +#### 해결 +1. **초록색 선 로직 완전 제거**: 중복 로직으로 인한 혼란 방지 +2. **주황색 선 로직 개선**: 모든 메인-메인 조인을 단일 로직으로 처리 + +```typescript +// 메인-메인 조인 엣지 생성 (단일 로직) +const joinEdges: Edge[] = []; + +// 모든 화면의 메인 테이블 목록 +const allMainTables = new Set(Object.values(screenTableMap)); + +focusedSubTablesData.subTables.forEach((subTable) => { + // 1. subTable.tableName이 다른 화면의 메인 테이블인 경우 + const isTargetMainTable = allMainTables.has(subTable.tableName) + && subTable.tableName !== focusedMainTable; + + if (isTargetMainTable) { + joinEdges.push({ + id: `edge-main-join-${focusedScreenId}-${subTable.tableName}-${focusedMainTable}`, + source: `table-${subTable.tableName}`, + target: `table-${focusedMainTable}`, + type: 'smoothstep', + animated: true, + style: { + stroke: '#ea580c', // 주황색 (단일 색상) + strokeWidth: 2, + strokeDasharray: '8,4', + }, + markerEnd: { type: MarkerType.ArrowClosed, color: '#ea580c' }, + }); + } + + // 2. fieldMappings.sourceTable이 메인 테이블인 경우도 처리 + // ... (parentMapping, rightPanelRelation 등) +}); +``` + +--- + +### 5. 메인테이블 fieldMappings 미전달 + +#### 문제 +- 서브테이블에만 `fieldMappings`가 전달되어 조인 정보 표시됨 +- 메인테이블에는 조인 컬럼 옆 연결 정보가 미표시 + +#### 해결 +메인테이블과 연관테이블에 각각 `fieldMappings` 생성: + +```typescript +// 메인테이블용 fieldMappings 생성 +let mainTableFieldMappings = []; +if (isFocusedTable && focusedSubTablesData) { + focusedSubTablesData.subTables.forEach((subTable) => { + subTable.fieldMappings?.forEach((mapping) => { + if (subTable.relationType === 'source' && mapping.sourceTable) { + mainTableFieldMappings.push({ + sourceField: mapping.sourceField, + targetField: mapping.targetField, + sourceDisplayName: mapping.sourceDisplayName, + targetDisplayName: mapping.targetDisplayName, + }); + } + // ... 기타 relationType 처리 + }); + }); +} + +// 연관 테이블용 fieldMappings 생성 +let relatedTableFieldMappings = []; +if (isRelatedTable && relatedTableInfo && focusedSubTablesData) { + // 이 테이블이 sourceTable인 경우의 매핑 추출 +} + +// node.data에 fieldMappings 전달 +return { + ...node, + data: { + ...node.data, + fieldMappings: isFocusedTable ? mainTableFieldMappings + : (isRelatedTable ? relatedTableFieldMappings : []), + }, +}; +``` + +--- + +### 6. 엣지 방향 오류 (참조 방향 반대) + +#### 문제 +- `customer_mng` 화면 포커스 시 `customer_item_mapping`으로 연결선이 표시됨 +- 실제로는 `customer_item_mapping.customer_id` → `customer_mng.customer_code` 참조 관계 +- 참조하는 쪽(A)이 아닌 참조당하는 쪽(B)에서 연결선이 나가는 오류 + +#### 원인 +```typescript +// 기존 잘못된 로직 +newEdges.push({ + id: edgeId, + source: `table-${mainTable}`, // 참조당하는 테이블 (잘못됨) + target: `table-${subTable.tableName}`, // 참조하는 테이블 (잘못됨) + // ... +}); +``` + +#### 해결 +```typescript +// 수정된 올바른 로직 - source/target 교환 +newEdges.push({ + id: edgeId, + source: `table-${subTable.tableName}`, // 참조하는 테이블 (올바름) + target: `table-${mainTable}`, // 참조당하는 테이블 (올바름) + // ... +}); +``` + +#### 동작 예시 +| 관계 | 포커스된 화면 | 예상 동작 | 실제 동작 (수정 후) | +|------|--------------|----------|-------------------| +| A가 B를 참조 | A 포커스 | A→B 연결선 ✅ | A→B 연결선 ✅ | +| A가 B를 참조 | B 포커스 | 연결선 없음 ✅ | 연결선 없음 ✅ | + +--- + +### 7. column_labels 필터링 누락 + +#### 문제 +- `inbound_mng.item_code`가 `item_info`를 참조하는 것으로 조인선 표시 +- 해당 필드는 과거 `entity` 타입이었다가 `text`로 변경됨 +- `reference_table` 데이터가 잔존하여 잘못된 조인 관계 생성 + +#### 원인 +```sql +-- 기존 잘못된 쿼리 +WHERE cl.reference_table IS NOT NULL + AND cl.reference_table != '' + AND cl.reference_table != suc.main_table + -- input_type 체크 없음! +``` + +#### 해결 +```sql +-- 수정된 쿼리 - input_type = 'entity' 조건 추가 +WHERE cl.reference_table IS NOT NULL + AND cl.reference_table != '' + AND cl.reference_table != suc.main_table + AND cl.input_type = 'entity' -- 추가됨 +``` + +--- + +## NTT 설정 소스별 처리 + +시스템에서 조인 정보가 정의되는 모든 소스를 범용적으로 처리합니다: + +| 소스 | 위치 | 처리 상태 | +|------|------|----------| +| `column_labels.reference_table` | 테이블 관리 | ✅ 처리됨 | +| `componentConfig.fieldMappings` | autocomplete, entity-search | ✅ 처리됨 | +| `componentConfig.columns.mapping` | modal-repeater-table | ✅ 처리됨 | +| `componentConfig.parentDataMapping` | selected-items-detail-input | ✅ 처리됨 | +| `componentConfig.rightPanel.relation` | split-panel-layout | ✅ 처리됨 | + +--- + +## API 인터페이스 변경 + +### FieldMappingInfo + +```typescript +export interface FieldMappingInfo { + sourceTable?: string; // 연관 테이블명 (parentDataMapping에서 사용) + sourceField: string; + targetField: string; + sourceDisplayName?: string; // 연관 테이블 컬럼 한글명 + targetDisplayName?: string; // 메인 테이블 컬럼 한글명 +} +``` + +### SubTableInfo + +```typescript +export interface SubTableInfo { + tableName: string; + componentType: string; + relationType: 'lookup' | 'source' | 'join' | 'reference' | 'parentMapping' | 'rightPanelRelation'; + fieldMappings?: FieldMappingInfo[]; +} +``` + +--- + +## 테스트 결과 + +### 판매품목정보 그룹 (3번째 화면 포커스) + +| 테이블 | 컬럼 | 표시 내용 | +|--------|------|----------| +| `customer_item_mapping` | 거래처 ID | ← 거래처 코드 (조인) | +| `customer_item_mapping` | 품목 ID | ← 품번 (조인) | +| `customer_mng` | 거래처 코드 | ← 거래처 ID (조인) | +| `item_info` | 품번 | ← 품목 ID (조인) | + +### 수주관리 그룹 (기존 정상 작동 확인) + +모든 조인 관계 및 컬럼 표시 정상 작동 + +--- + +## 범용성 검증 + +| 항목 | 상태 | +|------|------| +| 특정 테이블명 하드코딩 | ❌ 없음 | +| 특정 컬럼명 하드코딩 | ❌ 없음 | +| 조건 기반 분기 | ✅ `sourceTable` 존재 여부, `relationType`으로 판단 | +| 새로운 화면/그룹 적용 | ✅ 자동 적용 | + +--- + +## 시각화 결과 예시 + +### 포커스 전 (그룹 전체 선택) +``` +[화면1] ─── [화면2] ─── [화면3] + │ │ │ + ▼ ▼ ▼ +[item_info] [customer_mng] [customer_item_mapping] + (메인) (메인) (메인) +``` + +### 3번째 화면 포커스 시 +``` +[화면1] ─── [화면2] ─── [화면3] ← 활성 + │ │ │ + ▼ ▼ ▼ +[item_info] [customer_mng] [customer_item_mapping] + (연관) (연관) (메인/활성) + │ │ │ + │ │ ┌───────────┴───────────┐ + │ │ │ │ + └─────────────┼──────┤ 거래처 ID ← 거래처 코드 │ + │ │ 품목 ID ← 품번 │ + │ │ (+ 13개 사용 컬럼) │ + │ └───────────────────────┘ + ┌─────────────┘ + │ 거래처 코드 ← 거래처 ID + └───────────────────── +``` + +--- + +## 결론 + +1. **범용성 확보**: 모든 NTT 설정 소스에서 조인 관계 자동 추출 +2. **정확성 개선**: `relationType`과 `sourceTable` 기반 정확한 조인 컬럼 식별 +3. **사용자 경험 향상**: 한글명 표시 및 직관적인 연결 정보 제공 +4. **유지보수성**: 새로운 화면/그룹 추가 시 별도 코드 수정 불필요 +5. **메인-메인 조인 로직 단일화**: 기존 중복 로직(초록색/주황색)을 주황색 단일 로직으로 통합 + - `subTable.tableName`이 다른 화면의 메인 테이블인 경우 자동 연결 + - `fieldMappings.sourceTable` 없어도 메인 테이블 간 조인 감지 +6. **연관 테이블 포커싱 개선**: 포커스된 화면의 서브 테이블이 다른 화면의 메인 테이블인 경우 활성화 + - 회색 처리 대신 정상 표시 및 조인 컬럼 강조 + - `relatedMainTables` 생성 로직 확장으로 `subTable.tableName` 기반 감지 +7. **선 연결 규격 정립**: 일관되고 직관적인 연결선 방향 규격화 + - **메인-메인 연결선**: `bottom → bottom_target` 고정 (서브테이블 구간을 통해 연결) + - **서브 테이블 연결선**: `bottom → top` 고정 (아래로 문어발처럼 뻗어나감) + - **절대 규칙**: 선이 테이블이나 화면을 통과하지 않음 +8. **초기 메인-메인 엣지 생성**: 그룹 로딩 시점에 메인 테이블 간 연결선 미리 생성 + - 연한 주황색(`#fdba74`) 점선으로 기본 표시 + - 포커싱 시 진한 주황색(`#ea580c`)으로 강조 및 애니메이션 + - 중복 방지를 위한 `pairKey` 기반 Set 사용 +9. **ReactFlow Handle ID 구분**: 메인-메인 연결을 위한 핸들 추가 + - `TableNode`에 `id="bottom_target"` (type="target") 핸들 추가 + - 메인-메인 엣지는 `sourceHandle="bottom"`, `targetHandle="bottom_target"` 사용 + - 서브 테이블 엣지는 기존대로 `sourceHandle="bottom"`, `targetHandle="top"` 사용 +10. **메인-메인 강조 로직 개선**: 중복 연결선 및 잘못된 연결선 방지 + - 포커스된 메인 테이블이 `source`인 경우에만 해당 엣지 강조 + - 양방향 중복 강조 방지 (A→B와 B→A 동시 강조 안 함) + - 연결되지 않은 테이블에서 조인선이 나타나는 문제 해결 +11. **엣지 방향 수정**: 참조 방향에 맞게 엣지 source/target 교정 + - 기존 잘못된 방향: `mainTable → subTable.tableName` (참조당하는 테이블 → 참조하는 테이블) + - 수정된 올바른 방향: `subTable.tableName → mainTable` (참조하는 테이블 → 참조당하는 테이블) + - A가 B를 참조(entity 설정)하면: A 포커스 시 A→B 연결선 표시 + - B 포커스 시 연결선 없음 (B는 A를 참조하지 않으므로) +12. **column_labels 필터링 강화**: `input_type = 'entity'`인 경우만 참조 관계로 인정 + - `input_type = 'text'`인 경우 `reference_table`이 있어도 조인 관계로 취급하지 않음 + - 과거 entity 설정 후 text로 변경된 경우 잔존 데이터 무시 +13. **범용적 엣지 방향 결정 로직**: `relationType`에 따른 조건부 방향 결정 + - `parentMapping` (sourceTable 있음): `mainTable → sourceTable` 방향 + - `rightPanelRelation` (foreignKey 있음): `subTable → mainTable` 방향 + - `reference` (column_labels): `mainTable → subTable` 방향 + - 기본값: `subTable → mainTable` 방향 + +### 연결선 규격 다이어그램 + +``` +[화면1] [화면2] [화면3] [화면4] + │ │ │ │ + ▼ ▼ ▼ ▼ +[Table1] [Table2] [Table3] [Table4] ← 메인 테이블 + │ │ │ │ + │ bottom │ bottom │ bottom │ bottom + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────┐ ← 서브테이블 구간 +│ (메인-메인 연결선이 이 구간을 통과) │ +│ ◄── bottom ─ bottom_target ───► │ +│ (서브테이블 연결선도 이 구간에서 연결) │ +└─────────────────────────────────────────┘ + │ │ + ▼ top ▼ top + [서브1] [서브2] [서브3] ← 서브 테이블 +``` + +--- + +## [계획] 연결선 정리 시스템 설계 + +> **상태**: 요건 정의 단계 (미구현) + +### 배경 및 필요성 + +현재 시스템에서 연결선 종류가 증가함에 따라 체계적인 선 관리 시스템이 필요합니다. + +| 현재 상태 | 문제점 | +|-----------|--------| +| 조인선만 존재 | 종속 조회, 저장 테이블 등 추가 선 종류 필요 | +| 경로 규격 미정립 | 선이 테이블을 통과할 가능성 | +| 겹침 방지 미흡 | 같은 경로 사용 시 선 겹침 | + +--- + +### 절대 규칙 (위반 불가) + +1. **선이 테이블 노드를 가로로 통과하면 안됨** +2. **선이 화면 노드를 통과하면 안됨** +3. **같은 종류의 선은 같은 구간에서 이동** +4. **다른 종류의 선은 겹치지 않아야 함** + +--- + +### 연결선 종류 정의 + +#### 현재 구현됨 + +| 번호 | 선 종류 | 의미 | 색상 | 스타일 | 상태 | +|------|---------|------|------|--------|------| +| 1 | 화면 → 메인 테이블 | 화면이 사용하는 테이블 | 파란색 (`#3b82f6`) | 실선 | 구현됨 | +| 2 | 조인선 (엔티티 참조) | 테이블 간 데이터 병합 | 주황색 (`#ea580c`) | 점선 `8,4` | 구현됨 | + +#### 추가 예정 + +| 번호 | 선 종류 | 의미 | 색상 (예정) | 스타일 (예정) | 상태 | +|------|---------|------|-------------|---------------|------| +| 3 | 종속 조회선 (Master-Detail) | 선택 기준 필터 조회 | 시안/보라색 | 점선 `4,4` | 미구현 | +| 4 | 저장 테이블선 | 데이터 저장 대상 | 녹색 | 실선 or 점선 | 미구현 | +| 5+ | 기타 확장 | 데이터 플로우 등 | 미정 | 미정 | 미구현 | + +--- + +### 개념 구분: Join vs 종속 조회 + +| 구분 | Join (조인) | 종속 조회 (Filter) | +|------|------------|-------------------| +| **데이터 처리** | 두 테이블 **병합** | 한 테이블로 다른 테이블 **필터링** | +| **표시 방식** | 같은 그리드에 합쳐서 | 별도 그리드에 각각 | +| **SQL** | `JOIN ON A.key = B.key` | `WHERE B.key = (선택된 A.key)` | +| **예시** | `inbound_mng` + `item_info` 정보 합침 | `customer_mng` 선택 → `customer_item_mapping` 별도 조회 | +| **현재 표현** | 주황색 점선 | 미표현 | + +**더블 그리드 패턴 예시:** +``` +[customer_mng 그리드] | [customer_item_mapping 그리드] + (메인/선택) → (필터링된 결과) +``` +- 이 경우 `customer_mng`가 진짜 메인 +- `customer_item_mapping`은 **종속 조회** (메인이 아님, 선택에 따라 필터링) + +--- + +### 레이아웃 구간 정의 + +``` +Y: 0-200 ┌─────────────────────────────────────────────┐ + │ [화면1] [화면2] [화면3] [화면4] │ ← 화면 구간 + └─────────────────────────────────────────────┘ + │ │ │ │ +Y: 200-300 ════════════════════════════════════════════════ ← 화면-테이블 연결 구간 (파란 실선) + │ │ │ │ +Y: 300-500 ┌─────────────────────────────────────────────┐ + │ [Table1] [Table2] [Table3] [Table4] │ ← 메인 테이블 구간 + └─────────────────────────────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +Y: 500-700 ┌─────────────────────────────────────────────┐ + │ [서브1] [서브2] [서브3] │ ← 서브 테이블 구간 + └─────────────────────────────────────────────┘ + │ │ │ │ +Y: 700-750 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨1] 조인선 구간 (주황) +Y: 750-800 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨2] 종속 조회선 구간 (시안/보라) +Y: 800-850 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨3] 저장 테이블선 구간 (녹색) +Y: 850+ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨4+] 확장 구간 +``` + +--- + +### 선 경로 규격 + +#### 원칙 +1. **수직 이동**: 노드 사이 공간에서만 (노드 통과 안함) +2. **수평 이동**: 각 선 종류별 전용 레벨에서만 +3. **겹침 방지**: 서로 다른 Y 레벨 사용 + +#### 선 종류별 경로 + +| 선 종류 | 출발 핸들 | 도착 핸들 | 수평 이동 레벨 | +|---------|-----------|-----------|----------------| +| 화면 → 메인 | 화면 bottom | 테이블 top | Y: 200-300 (화면-테이블 구간) | +| 메인 → 서브 | 테이블 bottom | 서브 top | Y: 500-700 (서브 테이블 구간 내) | +| 조인선 (메인-메인) | 테이블 bottom | 테이블 bottom_target | **Y: 700-750** (레벨1) | +| 종속 조회선 | 테이블 bottom | 테이블 bottom_target | **Y: 750-800** (레벨2) | +| 저장 테이블선 | 테이블 bottom | 테이블 bottom_target | **Y: 800-850** (레벨3) | + +--- + +### 핸들 설계 + +현재 `TableNode`에 필요한 핸들: + +``` + [top] (target) ← 화면에서 오는 선 + │ + ┌────────┼────────┐ + │ │ │ + │ 테이블 노드 │ + │ │ + └────────┬────────┘ + │ + [bottom] (source) ← 나가는 선 (서브테이블, 조인 등) + │ + [bottom_target] (target) ← 메인-메인 연결용 들어오는 선 +``` + +**추가 필요 핸들 (겹침 방지용):** + +``` + ┌────────────────────────┐ + │ │ + │ 테이블 노드 │ + │ │ + └───┬───────┬───────┬────┘ + │ │ │ + (30%) (50%) (70%) ← X 위치 + │ │ │ + [bottom_join] [bottom] [bottom_filter] + │ │ + 조인선 전용 종속 조회선 전용 +``` + +--- + +### 구현 방안 비교 + +--- + +#### 방안 A: 커스텀 엣지 경로 (완벽 분리) + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [화면1] [화면2] [화면3] [화면4] │ Y: 0-200 +│ │ │ │ │ │ +└────┼─────────────┼──────────────┼──────────────┼────────────────────┘ + │ │ │ │ +═════╧═════════════╧══════════════╧══════════════╧════════════════════ Y: 250 (화면-테이블 구간) + │ │ │ │ +┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐ +│ [Table1] [Table2] [Table3] [Table4] │ Y: 300-500 +│ │ │ │ │ │ +└────┼─────────────┼──────────────┼──────────────┼────────────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ [서브1] [서브2] [서브3] │ Y: 550-650 +└─────────────────────────────────────────────────────────────────────┘ + │ │ +─────┴───────────── 레벨1: 조인선 (주황) ────────┴───────────────────── Y: 700 + │ │ + └───────────────────────────────────────────┘ + │ │ +─────┴───────────── 레벨2: 종속 조회 (보라) ─────┴───────────────────── Y: 750 + │ │ + └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ + │ │ +─────┴───────────── 레벨3: 저장 테이블 (녹색) ───┴───────────────────── Y: 800 + │ │ + └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ +``` + +**특징**: 각 선 종류가 **전용 Y 레벨**에서만 수평 이동. 절대 겹치지 않음. + +**코드 예시:** + +```typescript +// 커스텀 경로 계산 +const getCustomPath = (source, target, lineType) => { + const levels = { + join: 725, // 조인선 Y 레벨 + filter: 775, // 종속 조회선 Y 레벨 + save: 825, // 저장 테이블선 Y 레벨 + }; + + const level = levels[lineType]; + + // 수직 → 수평(레벨) → 수직 경로 + return `M${source.x},${source.y} + L${source.x},${level} + L${target.x},${level} + L${target.x},${target.y}`; +}; +``` + +**장점:** +- 완벽한 분리 보장 +- 절대 규칙 100% 준수 +- 확장성 우수 + +**단점:** +- 구현 복잡도 높음 +- ReactFlow 기본 기능 대신 커스텀 필요 + +--- + +#### 방안 B: 핸들 위치 분리 + smoothstep + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [화면1] [화면2] [화면3] [화면4] │ +└────┬─────────────┬──────────────┬──────────────┬────────────────────┘ + │ │ │ │ +┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐ +│ [Table1] [Table2] [Table3] [Table4] │ +│ 30% 50% 70% 30% 50% 70% │ ← 핸들 X 위치 +│ │ │ │ │ │ │ │ +└───┼───┼───┼──────────────────────────────────┼───┼───┼──────────────┘ + │ │ │ │ │ │ + │ │ └── 종속 조회 (보라) ──────────────┼───┼───┘ ← 70% 위치 + │ │ │ │ + │ └────── 조인선 (주황) ─────────────────┼───┘ ← 50% 위치 + │ │ + └────────── 저장 테이블 (녹색) ────────────┘ ← 30% 위치 + + ※ 시작점은 다르지만 경로가 가까움 (smoothstep 자동 계산) + ※ 선이 가까이 지나가서 겹칠 가능성 있음 +``` + +**특징**: 핸들 X 위치만 다름. **경로가 가까이 지나가서 겹칠 가능성** 있음. + +**코드 예시:** + +```typescript +// 핸들 위치로 분리 + + + +// smoothstep + offset +edge.pathOptions = { offset: lineType === 'filter' ? 50 : 0 }; +``` + +**장점:** +- ReactFlow 기본 기능 활용 +- 구현 상대적 단순 + +**단점:** +- 완벽한 분리 보장 어려움 +- 복잡한 경우 선 겹침 가능 + +--- + +#### 방안 C: 선 대신 마커/뱃지 (종속 조회만) + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ [화면1] [화면2] [화면3] [화면4] │ +└────┬─────────────┬──────────────┬──────────────┬────────────────────┘ + │ │ │ │ +┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐ +│ [Table1] [Table2] [Table3] [Table4 🔗] │ +│ │ ↑ │ +│ │ "Table1에서 필터 조회" (툴팁) │ +│ │ │ +│ └────────── 조인선만 표시 (주황) ──────────┘ │ +│ │ +│ ※ 종속 조회, 저장 테이블은 선 없이 뱃지/아이콘으로만 표시 │ +│ ※ 마우스 오버 시 관계 정보 툴팁 표시 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ [서브1] [서브2] [서브3] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**특징**: 선 없음 = **겹침/통과 문제 없음**. 하지만 관계 시각화 약함. + +**코드 예시:** + +```typescript +// 테이블 노드에 관계 뱃지 표시 + + + +``` + +**장점:** +- 선 없음 = 겹침/통과 문제 없음 +- 화면 깔끔 + +**단점:** +- 관계 시각화 약함 +- 일관성 부족 (조인은 선, 종속은 뱃지) + +--- + +#### 방안 D: 하이브리드 (커스텀 경로 통일) - 권장 + +**레이어 다이어그램:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 0: 화면 노드 │ +│ [화면1] [화면2] [화면3] [화면4] │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 1: 화면-테이블 연결 (파란 실선) │ +│ ═══════════════════════════════════════════════════════════════════ │ Y: 250 +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 2: 메인 테이블 노드 │ +│ [Table1] [Table2] [Table3] [Table4] │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 3: 서브 테이블 노드 │ +│ [서브1] [서브2] [서브3] │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 4: 조인선 구간 (주황색) │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 700-725 +│ Table1 ──────────────────────────────────► Table4 │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 5: 종속 조회 구간 (보라색) │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 750-775 +│ Table1 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► Table3 │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 6: 저장 테이블 구간 (녹색) │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 800-825 +│ Table2 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► Table4 │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ 레이어 7+: 확장 가능 │ +│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 850+ +│ (미래 확장용) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**특징**: 모든 선이 **전용 레이어**에서 이동. 확장성 최고. 절대 겹치지 않음. + +**구현 방식:** +- **조인선**: 커스텀 경로 (방안 A) - 레벨4 (Y: 700-725) +- **종속 조회선**: 커스텀 경로 (방안 A) - 레벨5 (Y: 750-775) +- **저장 테이블선**: 커스텀 경로 (방안 A) - 레벨6 (Y: 800-825) + +모든 선을 동일한 커스텀 경로 시스템으로 통일. + +**장점:** +- 일관된 시스템 +- 완벽한 분리 +- 확장성 최고 +- 절대 규칙 100% 준수 + +**단점:** +- 초기 구현 비용 높음 + +--- + +### 방안 비교 요약표 + +| 방안 | 겹침 방지 | 절대규칙 준수 | 구현 복잡도 | 확장성 | 시각적 일관성 | +|------|-----------|---------------|-------------|--------|---------------| +| **A** | 완벽 | 100% | 높음 | 좋음 | 좋음 | +| **B** | 불완전 | 90% | 낮음 | 보통 | 보통 | +| **C** | 완벽 (선 없음) | 100% | 낮음 | 좋음 | 약함 | +| **D** | **완벽** | **100%** | 높음 | **최고** | **최고** | + +--- + +### 구현 우선순위 (제안) + +| 순서 | 작업 | 설명 | +|------|------|------| +| 1 | 커스텀 엣지 컴포넌트 개발 | 레벨 기반 경로 계산 | +| 2 | 기존 조인선 마이그레이션 | smoothstep → 커스텀 경로 | +| 3 | 종속 조회선 구현 | 레벨2 경로 + 시안/보라 색상 | +| 4 | 저장 테이블선 구현 | 레벨3 경로 + 녹색 | +| 5 | 테스트 및 최적화 | 다양한 그룹에서 검증 | + +--- + +### 색상 팔레트 (확정 - 2026-01-08) + +| 관계 유형 | 시각적 유형 | 기본 색상 | 강조 색상 | 설명 | +|-----------|-------------|-----------|-----------|------| +| 화면 → 메인 | - | `#3b82f6` (파랑) | `#2563eb` | 화면-테이블 연결 | +| 마스터-디테일 | `filter` | `#8b5cf6` (보라) | `#c4b5fd` | split-panel의 필터링 관계 | +| 계층 구조 | `hierarchy` | `#06b6d4` (시안) | `#a5f3fc` | 부모-자식 자기 참조 | +| 코드 참조 | `lookup` | `#f59e0b` (주황) | `#fcd34d` | autocomplete 등 코드→명칭 | +| 데이터 매핑 | `mapping` | `#10b981` (녹색) | `#6ee7b7` | parentDataMapping | +| 엔티티 조인 | `join` | `#ea580c` (주황) | `#fdba74` | 실제 LEFT/INNER JOIN | + +--- + +## 관계 유형 추론 시스템 (2026-01-08 구현) + +### 구현 개요 + +테이블 간 연결선이 단순 "조인"만이 아니라 다양한 관계 유형을 가지므로, +기존 컴포넌트 설정을 기반으로 관계 유형을 추론하여 시각화에 반영합니다. + +### 관계 유형 분류 + +| 유형 | 기술적 의미 | 컴포넌트 | 식별 조건 | +|------|------------|----------|-----------| +| `filter` | 마스터-디테일 필터링 | split-panel-layout | `relationType='rightPanelRelation'` + `originalRelationType='join'` | +| `hierarchy` | 자기 참조 계층 구조 | split-panel-layout | `relationType='rightPanelRelation'` + `originalRelationType='detail'` | +| `mapping` | 데이터 참조 주입 | selected-items-detail-input | `relationType='parentMapping'` | +| `lookup` | 코드→명칭 변환 | autocomplete, entity-search | `relationType='lookup'` | +| `join` | 실제 엔티티 조인 | column_labels 참조 | `relationType='reference'` | + +### 백엔드 수정 사항 + +`screenGroupController.ts`의 `getScreenSubTables` 함수에서 추가 필드 전달: + +```typescript +// rightPanel.relation 파싱 시 +screenSubTables[screenId].subTables.push({ + tableName: subTable, + componentType: componentType, + relationType: 'rightPanelRelation', + originalRelationType: relation?.type || 'join', // 추가: 원본 relation.type + foreignKey: relation?.foreignKey, // 추가: FK 컬럼 + leftColumn: relation?.leftColumn, // 추가: 마스터 컬럼 + fieldMappings: ..., +}); +``` + +### 프론트엔드 수정 사항 + +1. **타입 확장** (`screenGroup.ts`): + - `SubTableInfo`에 `originalRelationType`, `foreignKey`, `leftColumn` 필드 추가 + - `VisualRelationType` 타입 정의: `'filter' | 'hierarchy' | 'lookup' | 'mapping' | 'join'` + - `inferVisualRelationType()` 함수 추가 + +2. **시각화 적용** (`ScreenRelationFlow.tsx`): + - `RELATION_COLORS` 상수 정의 (관계 유형별 색상) + - 엣지 생성 시 `inferVisualRelationType()` 호출 + - 엣지 스타일에 관계 유형별 색상 적용 + +### 2026-01-09 추가 수정 사항 + +1. **방향 수정**: `rightPanelRelation` 엣지 방향을 `mainTable → subTable`로 변경 + - 이전: `customer_item_mapping → customer_mng` (디테일 → 마스터, 잘못됨) + - 수정: `customer_mng → customer_item_mapping` (마스터 → 디테일, 올바름) + +2. **화면별 엣지 분리**: 같은 테이블 쌍이라도 화면별로 별도 엣지 생성 + - `pairKey`에 `screenId` 포함: `${sourceScreenId}-${[mainTable, subTable].sort().join('-')}` + - `edgeId`에 `screenId` 포함: `edge-main-main-${sourceScreenId}-${referrerTable}-${referencedTable}` + +3. **포커스 필터링 개선**: 해당 화면에서 생성된 연결선만 표시 + - 이전: `sourceTable === focusedMainTable` 조건만 체크 (다른 화면 연결선도 표시됨) + - 수정: `edge.data.sourceScreenId === focusedScreenId` 조건으로 변경 + +4. **parentMapping을 join으로 변경**: `selected-items-detail-input`의 `parentDataMapping`은 FK 관계이므로 `join`으로 분류 + - `customer_item_mapping → customer_mng`: 주황색 (FK: customer_id → customer_code) + - `customer_item_mapping → item_info`: 주황색 (FK: item_id → item_number) + +5. **참조 테이블 시각적 표시**: lookup/reference 관계로 참조되는 테이블에 "X곳 참조" 배지 표시 + - `TableNodeData`에 `referencedBy` 필드 추가 + - `ReferenceInfo` 인터페이스 정의 (fromTable, fromColumn, toColumn, relationType) + - 테이블 노드 헤더에 주황색 배지로 참조 카운트 표시 + - 툴팁에 참조하는 테이블 목록 표시 + +6. **마스터-디테일 필터링 관계 표시**: 디테일 테이블에 "X 필터" 배지 표시 + - 마스터-디테일 관계(rightPanelRelation)도 참조 정보 수집에 추가 + - **보라색 배지**로 "customer_mng 필터" 형태로 표시 + - 툴팁에 FK 컬럼 정보 표시 (예: "customer_mng에서 필터링 (FK: customer_id)") + - lookup 관계는 주황색, filter 관계는 보라색으로 구분 + +7. **FK 컬럼 보라색 강조 + 키값 정보 표시** + - 디테일 테이블에서 필터링에 사용되는 FK 컬럼을 **보라색 배경**으로 강조 + - 컬럼 옆에 참조 정보 표시: "← customer_mng.customer_code" + - 배지에 키값 정보 명확히 표시: "customer_mng.customer_code 필터" + - `TableNodeData`에 `filterColumns` 필드 추가 + - `ReferenceInfo`에서 `toColumn` 정보로 FK 컬럼 식별 + +8. **포커스 상태 기반 필터 표시 개선** + - **문제**: 필터 배지가 모든 화면에서 항상 표시되어 혼란 발생 + - **해결**: 포커스된 화면에서만 해당 관계의 필터 정보 표시 + - 노드 생성 시 `referencedBy`, `filterColumns` 제거 + - `styledNodes` 함수에서 포커스 상태에 따라 동적으로 설정 + - 배지를 헤더 아래 별도 영역으로 이동하여 테이블명 가림 방지 + +**결과:** +| 화면 | customer_item_mapping 표시 | +|------|----------------------------| +| 1번 화면 포커스 | 필터 배지 O + FK 컬럼 보라색 + **상단 정렬** | +| 4번 화면 포커스 | 필터 배지 X, 조인만 표시 | +| 그룹 선택 (포커스 없음) | 필터 배지 X, 테이블명만 표시 | + +9. **필터 컬럼 상단 정렬** + - 필터 컬럼도 파란색/주황색 컬럼처럼 상단에 정렬되어 표시 + - `potentialFilteredColumns`에 `filterSet` 포함 + - 정렬 순서: **조인 컬럼 → 필터 컬럼 → 사용 컬럼** + - 보라색 강조로 필터링 관계 명확히 구분 + +**정렬 우선순위:** +| 순서 | 컬럼 유형 | 색상 | 설명 | +|------|----------|------|------| +| 1 | 조인 컬럼 | 주황색 | FK 조인 관계 | +| 2 | 필터 컬럼 | 보라색 | 마스터-디테일 필터링 | +| 3 | 사용 컬럼 | 파란색 | 화면 필드 매핑 | + +10. **방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션** + - 필터 관계는 선 없이 뱃지 + 테이블 테두리로만 표시 (겹침 방지) + - 필터링된 테이블에 **보라색 테두리** 적용 (부드러운 색상 전환) + - 조인선(주황)만 표시, 필터선(보라) 제거 + +11. **테이블 높이 부드러운 애니메이션** + - 포커스 시 컬럼 목록이 변경될 때 **부드러운 높이 전환** 적용 + - `transition: height 0.5s cubic-bezier(0.4, 0, 0.2, 1)` 사용 + - **Debounce 로직** (50ms): 듀얼 그리드에서 filterColumns와 joinColumns가 2단계로 업데이트되는 문제 해결 + - 중간 값(늘어났다가 줄어드는 현상) 무시, 최종 값만 적용 + +12. **뱃지 영역 레이아웃 개선** + - 뱃지를 컬럼 목록 영역 **안에 포함** (높이 늘어남 방지) + - `calculatedHeight`에 뱃지 높이(26px) 포함하여 계산 + - 뱃지와 컬럼 동시 변경으로 "늘어났다가 줄어드는" 현상 해결 + +13. **뱃지 스타일 개선** + - 회색 테두리 (`border-slate-300`) + 연한 배경 (`bg-slate-50`) + - 보라색 컬럼과 확실히 구분되는 디자인 + - 필터 태그: 보라색 pill 스타일 (`rounded-full bg-violet-600`) + +**시각적 표현:** +| 관계 유형 | 선 표시 | 테두리 | 배지 | +|----------|---------|--------|------| +| 조인 | ✅ 주황색 점선 | - | "조인" | +| 필터 | ❌ 없음 | 보라색 (부드러운 전환) | "필터 + 키값" | +| 룩업 | ✅ 황색 점선 | - | "N곳 참조" | + +**구현 상세:** +- `ScreenRelationFlow.tsx`: `visualRelationType === 'filter'`인 경우 엣지 생성 건너뛰기 +- `ScreenNode.tsx`: + - `hasFilterRelation` 조건으로 보라색 테두리 + 부드러운 색상 전환 적용 + - `calculatedHeight`에 뱃지 높이 포함 + - `debouncedHeight` 사용으로 중간 값 무시 + - 뱃지를 컬럼 목록 div 안에 배치 + +### 향후 개선 가능 사항 + +1. [ ] 범례(Legend) UI 추가 - 관계 유형별 색상 설명 +2. [ ] 엣지 라벨에 관계 유형 표시 +3. [x] 툴팁에 상세 관계 정보 표시 (FK, 연결 컬럼 등) - 완료 + +--- + +### 다음 단계 + +1. [x] 방안 확정 - 방안 1 (추론 로직) 선택 +2. [x] 색상 팔레트 확정 +3. [x] 관계 유형 추론 함수 구현 +4. [x] 방향 및 포커스 필터링 수정 +5. [x] parentMapping을 join으로 변경 +6. [x] 참조 테이블 시각적 표시 추가 +7. [x] 마스터-디테일 필터링 관계 표시 추가 +8. [x] FK 컬럼 보라색 강조 + 키값 정보 표시 +9. [x] 포커스 상태 기반 필터 표시 개선 +10. [x] 필터 컬럼 상단 정렬 (조인 → 필터 → 사용 순서) +11. [x] 방안 C 적용: 필터선 제거 + 보라색 테두리 (펄스 → 부드러운 전환으로 변경) +12. [x] 테이블 높이 부드러운 애니메이션 + Debounce 적용 +13. [x] 뱃지 영역 레이아웃 개선 (컬럼 목록 안에 포함) +14. [x] 뱃지 스타일 개선 (회색 테두리로 컬럼과 구분) +15. [x] 서브테이블 Y 좌표 조정 (690px → 740px) +16. [x] **저장 테이블 시각화** (구현 완료) +17. [x] 테이블 스크롤 기능 추가 (maxHeight + overflow-y-auto) +18. [x] 테이블/헤더 둥근 모서리 (rounded-xl, rounded-t-xl) +19. [x] 필터 테이블 조인선 + 참조 테이블 활성화 +20. [x] 조인선 색상 상수 통일 (RELATION_COLORS.join.stroke) +21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시) +22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData) +23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입) +24. [ ] **선 교차점 이질감 해결** (계획 중) +22. [ ] 범례 UI 추가 (선택사항) +23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) + +--- + +## 저장 테이블 시각화 (구현 완료) + +### 개요 +화면에서 데이터가 **어떤 테이블에 저장**되는지 시각화 + +### 저장 테이블 유형 + +| 유형 | 설명 | 예시 | +|------|------|------| +| **메인 저장** | 화면의 메인 테이블에 직접 저장 | 수주등록 → `sales_order_mng` | +| **연계 저장** | 버튼 클릭 → 다른 화면의 테이블에 저장 | 수주관리 → 출하계획 → `shipment_plan` | +| **서브 저장** | 듀얼 그리드에서 서브 테이블에 저장 | 거래처관리 → `customer_item_mapping` | + +### 데이터 수집 방법 (백엔드) + +저장 테이블 정보를 찾을 수 있는 곳: +1. `componentConfig.action.type = 'save'` (edit, delete 제외) +2. `componentConfig.targetTable` (modal-repeater-table 등) +3. `action.dataTransfer.targetTable` (데이터 전송 대상) + +**제외 조건:** +1. `action.targetScreenId IS NOT NULL` (모달 열기 버튼) +2. `table-list` + 체크박스 활성화 + `openModalWithData` 버튼이 있는 화면 + - 예: "거래처별 품목 추가 모달" - 선택 후 다음 화면으로 넘기는 패턴 + - 이 경우 "저장" 버튼은 DB 저장이 아닌 **선택 확인 용도** + +```sql +-- 제외 조건 SQL +AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_list + WHERE sl_list.screen_id = sd.screen_id + AND sl_list.properties->>'componentType' = 'table-list' + AND (sl_list.properties->'componentConfig'->'checkbox'->>'enabled')::boolean = true +) +AND NOT EXISTS ( + SELECT 1 FROM screen_layouts sl_modal + WHERE sl_modal.screen_id = sd.screen_id + AND sl_modal.properties->'componentConfig'->'action'->>'type' = 'openModalWithData' +) +``` + +### 시각적 표현 (구현됨) + +**핑크색 막대기 표시** +- 테이블 노드 **왼쪽 바깥**에 핑크색 세로 막대기 표시 +- 위에서 아래로 나타나는 애니메이션 (`scaleY` 트랜지션) +- 포커스 해제 시 사라지는 애니메이션 +- 막대기 양끝 그라데이션 (투명 → 핑크 → 투명) + +**스타일:** +```css +/* 저장 막대기 스타일 */ +position: absolute; +left: -6px; /* -left-1.5 */ +top: 4px; +bottom: 4px; +width: 2px; /* w-0.5 */ +background: linear-gradient( + to bottom, + transparent 0%, + #f472b6 15%, /* pink-400 */ + #f472b6 85%, + transparent 100% +); +transition: all 0.5s ease-out; +transform-origin: top; +``` + +**애니메이션:** +- 포커스 시: `opacity: 1, scaleY: 1` (나타남) +- 포커스 해제 시: `opacity: 0, scaleY: 0` (사라짐) + +### 색상 팔레트 + +| 관계 유형 | 선 색상 | 뱃지/막대 색상 | 컬럼 강조 | +|----------|---------|---------------|----------| +| 조인 | 주황 (#F97316) | 주황 | 주황 | +| 필터 | - | 보라 (#8B5CF6) | 보라 | +| 룩업 | 황색 (#EAB308) | 황색 | - | +| **저장** | - | 핑크 (#F472B6) | - | + +### 구현 단계 (완료) + +1. [x] 백엔드: `getScreenSubTables`에서 저장 테이블 정보 추출 +2. [x] 타입 정의: `SaveTableInfo` 인터페이스 추가 +3. [x] 프론트엔드: 핑크색 막대기 UI 구현 +4. [x] 프론트엔드: 포커싱 시에만 표시 +5. [x] 프론트엔드: 나타나기/사라지기 애니메이션 +6. [ ] 프론트엔드: 뱃지 클릭 시 팝오버 상세정보 (향후) + +--- + +## 필터 테이블 조인선 시각화 (구현 완료) + +### 개요 +마스터-디테일 관계에서 **필터 대상 테이블**이 **다른 테이블과 조인**하는 경우도 시각화 + +### 시나리오 +"거래처관리 화면" (1번 화면) 포커싱 시: +- `customer_mng` (마스터) → `customer_item_mapping` (디테일) 필터 관계 +- `customer_item_mapping` → `item_info` **조인 관계** (품목 ID → 품번) + +### 구현 내용 + +1. **화면 → 필터 대상 테이블 연결선** + - 파란색 점선으로 화면 → `customer_item_mapping` 연결 + - 기존 `customer_mng`로만 가던 연결 외에 추가 + +2. **필터 대상 테이블의 조인선** + - `customer_item_mapping` → `item_info` 주황색 점선 조인선 + - `joinColumnRefs` 기반으로 자동 생성 + +3. **참조 테이블 활성화** + - `item_info` 테이블도 함께 활성화 (회색 처리 안 함) + - 조인 컬럼 주황색 강조 표시 + +### 포커싱 제어 + +**조인선 (주황색 점선)** +- 해당 화면이 포커싱됐을 때만 활성화 +- 다른 화면 포커싱 시 흐리게 처리 (opacity: 0.3) +- 엣지 ID: `edge-filter-join-{screenId}-{sourceTable}-{targetTable}` + +**필터 연결선 (파란색 점선)** +- 화면 → 필터 대상 테이블 연결선 +- 해당 화면이 포커싱됐을 때만 표시 (opacity: 1) +- 포커스 해제 시 완전히 숨김 (opacity: 0) +- 엣지 ID: `edge-screen-filter-{screenId}-{tableName}` + +**styledEdges 처리:** +```typescript +// 필터 조인 엣지 (주황색) +if (edge.id.startsWith("edge-filter-join-")) { + const isActive = focusedScreenId === edgeSourceScreenId; + return { + ...edge, + style: { + stroke: isActive ? RELATION_COLORS.join.stroke : RELATION_COLORS.join.strokeLight, + opacity: isActive ? 1 : 0.3, + }, + }; +} + +// 화면 → 필터 대상 테이블 연결선 (파란색) +if (edge.id.startsWith("edge-screen-filter-")) { + const isActive = focusedScreenId === edgeSourceScreenId; + return { + ...edge, + style: { + opacity: isActive ? 1 : 0, // 포커스 해제 시 완전히 숨김 + }, + }; +} +``` + +### 코드 위치 +- `ScreenRelationFlow.tsx`: 필터 조인 엣지 생성 + styledEdges 처리 +- `styledNodes`: 필터 대상 테이블의 조인 참조 테이블 활성화 로직 + +--- + +## 테이블 노드 UI 개선 (구현 완료) + +### 스크롤 기능 +- 컬럼이 많을 경우 스크롤 가능 (`overflow-y-auto`) +- 최대 높이 제한 (`maxHeight: 300px`) +- 얇은 스크롤바 (`scrollbar-thin`) + +### 둥근 모서리 +- 테이블 전체: `rounded-xl` (12px) +- 헤더: `rounded-t-xl` (상단만 12px) + +### 조인선 색상 통일 +- 모든 조인선이 `RELATION_COLORS.join.stroke` 상수 사용 +- 기본 색상: `#f97316` (orange-500) +- 강조 색상: `#ea580c` (orange-600) + +### 첫 진입 시 포커싱 없이 시작 + +**문제:** +- 트리에서 화면을 클릭하면 해당 화면이 자동 포커싱됨 +- 첫 진입 시 노드 위치가 안정화되기 전에 필터선이 그려져 "망가진" 모습 + +**해결:** +- 트리에서 화면 클릭 시: 그룹만 진입, 포커싱 없음 +- ReactFlow 안에서 화면 클릭 시: 정상 포커싱 + +**코드 변경:** +```typescript +// page.tsx - onScreenSelectInGroup 콜백 +onScreenSelectInGroup={(group, screenId) => { + const isNewGroup = selectedGroup?.id !== group.id; + + if (isNewGroup) { + // 새 그룹 진입: 포커싱 없이 시작 + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); +}} +``` + +**사용자 경험:** +1. 트리에서 화면 클릭 (첫 진입) → 깔끔한 초기 상태 (모든 화면/테이블 동일 밝기) +2. 같은 그룹 내에서 다른 화면 클릭 → 포커싱 + 연결선 표시 +3. ReactFlow에서 화면 노드 클릭 → 포커싱 + 연결선 표시 + +--- + +## [계획] 선 교차점 이질감 해결 + +> **상태**: 방안 검토 중 (미구현) + +### 배경 +여러 파란색 연결선이 서로 교차할 때 시각적 이질감 발생 + +### 해결 방안 + +#### 방안 C: 배경색 테두리 (Outline) - 권장 +- 각 선에 **흰색 테두리(outline)** 추가 +- 교차할 때 위에 있는 선이 아래 선을 "덮는" 효과 +- SVG stroke에 흰색 outline 적용 + +**구현 방식:** +```typescript +// 커스텀 엣지 컴포넌트에서 + + +``` + +**장점:** +- 구현 비교적 쉬움 +- 교차점이 깔끔하게 분리되어 보임 +- 핸들 위치/경로 변경 없음 + +**단점:** +- 선이 약간 두꺼워 보일 수 있음 + +--- + +## 화면 관리 시스템 업그레이드 현황 + +### 프로젝트 개요 + +화면 관리 시스템 업그레이드를 통해 다음 3가지 핵심 기능을 구현: + +| 기능 | 설명 | 상태 | +|------|------|------| +| **화면 그룹핑** | 관련 화면들을 그룹으로 묶어 관리 (트리 구조) | 기본 구현 완료 | +| **화면-테이블 관계 시각화** | React Flow를 사용한 노드 기반 시각화 | 기본 구현 완료 | +| **테이블 조인 설정** | 화면 내에서 테이블 간 조인 관계 직접 설정 | 미구현 | + +--- + +### 데이터베이스 테이블 (5개) + +| 테이블명 | 용도 | 상태 | +|----------|------|------| +| `screen_groups` | 화면 그룹 정보 | 생성됨 | +| `screen_group_screens` | 화면-그룹 연결 (N:M) | 생성됨 | +| `screen_field_joins` | 화면 필드 조인 설정 | 생성됨 | +| `screen_data_flows` | 화면 간 데이터 흐름 | 생성됨 | +| `screen_table_relations` | 화면-테이블 관계 | 생성됨 | + +--- + +### 백엔드 API 현황 + +| 파일 | 상태 | 엔드포인트 | +|------|------|-----------| +| `screenGroupController.ts` | 완성됨 | 그룹/화면/조인/흐름/관계 CRUD | +| `screenGroupRoutes.ts` | 완성됨 | `/api/screen-groups/*` | + +--- + +### 프론트엔드 컴포넌트 현황 + +| 컴포넌트 | 경로 | 상태 | +|----------|------|------| +| `ScreenGroupTreeView.tsx` | `components/screen/` | **완료** | +| `ScreenGroupModal.tsx` | `components/screen/` | **완료** (그룹 CRUD 모달) | +| `ScreenRelationFlow.tsx` | `components/screen/` | **완료** | +| `ScreenNode.tsx` | `components/screen/` | **완료** | +| `FieldJoinPanel.tsx` | `components/screen/panels/` | **완료** (조인 설정) | +| `DataFlowPanel.tsx` | `components/screen/panels/` | **완료** (데이터 흐름 설정) | +| API 클라이언트 | `lib/api/screenGroup.ts` | **완료** | + +--- + +### 구현 완료 목록 + +| # | 항목 | 완료일 | +|---|------|--------| +| 1 | DB 테이블 5개 생성 및 메타데이터 등록 | - | +| 2 | 백엔드 API 전체 구현 (CRUD) | - | +| 3 | 프론트엔드 API 클라이언트 구현 | - | +| 4 | 트리 뷰 기본 구현 (그룹/화면 표시) | - | +| 5 | React Flow 시각화 기본 구현 (노드 배치, 연결선) | - | +| 6 | 노드 디자인 1차 개선 (정사각형, 흰색 테마) | - | +| 7 | 화면 레이아웃 요약 API 추가 | 2026-01-01 | +| 8 | 화면 노드 미리보기 구현 (폼/그리드/대시보드) | 2026-01-01 | +| 9 | 테이블 노드 개선 (PK/FK 아이콘, 컬럼 목록) | 2026-01-01 | +| 10 | 연결선 스타일 개선 (CRUD 라벨 제거, 1:N 표시) | 2026-01-01 | + +--- + +### 추가 구현 완료 목록 + +| # | 항목 | 컴포넌트 | 상태 | +|---|------|----------|------| +| 11 | **그룹 관리 UI** | `ScreenGroupModal.tsx` | **완료** | +| 12 | **조인 설정 UI** | `FieldJoinPanel.tsx` (414줄) | **완료** | +| 13 | **데이터 흐름 설정 UI** | `DataFlowPanel.tsx` (462줄) | **완료** | + +--- + +### 미구현 작업 목록 (UI 선택사항) + +| # | 항목 | 설명 | 우선순위 | +|---|------|------|----------| +| 1 | **화면 미리보기 고도화** | 실제 컴포넌트 렌더링, 더 상세한 폼 필드 표시 | 낮음 | +| 2 | 범례(Legend) UI 추가 | 관계 유형별 색상 설명 | 낮음 | +| 3 | 뱃지 클릭 시 팝오버 상세정보 | 저장/필터/조인 뱃지 클릭 시 상세 정보 | 낮음 | +| 4 | 선 교차점 이질감 해결 | 배경색 테두리 방식 | 낮음 | + +--- + +## [다음 단계] 노드 플로워 기반 화면-테이블 설정 시스템 + +### 배경 및 목적 + +**문제**: 화면 디자이너에 너무 많은 기능이 집중되어 있음 +- 조인 설정, 필터 설정, 필드-컬럼 매칭, 저장 테이블 설정 등 + +**해결책**: 화면 관리 노드 플로워에서 이러한 설정을 **직접** 할 수 있게 함 +- 노드 플로워 = 화면-테이블 관계 설정의 **또 다른 UI** +- 시각적으로 설정하고, DB에 저장되면 화면 디자이너/실제 화면에 자동 반영 + +### 핵심 개념 + +``` +노드 플로워에서 화면/테이블 노드 클릭 (우클릭/더블클릭) + ↓ + 모달/팝업 열림 + ↓ + 설정 (조인, 필터, 필드-컬럼 매칭, 저장 테이블 등) + ↓ + DB 저장 (screen_layouts.properties, screen_field_joins 등) + ↓ + 시각화 자동 반영 (데이터 기반으로 그리니까) + 화면 디자이너 자동 반영 (같은 데이터 사용) + 실제 화면 자동 반영 (같은 데이터 사용) +``` + +### 구현 대상 기능 + +| 기능 | 설명 | +|------|------| +| **테이블 연결 설정** | 화면이 어떤 테이블과 연결되는지 | +| **테이블 조인 설정** | 테이블 간 조인 관계 (LEFT, INNER 등) | +| **필터링 설정** | 마스터-디테일 필터링 관계 | +| **필드-컬럼 매칭** | 화면 필드 ↔ 테이블 컬럼 매핑 | +| **저장 테이블 설정** | 어떤 테이블에 데이터가 저장되는지 | + +### 구현 방안 (초안, 미확정) + +#### 방안 A: 통합 설정 모달 + +노드 클릭 시 **하나의 모달**에서 탭으로 모든 설정 + +``` +[화면 노드] 더블클릭 + ↓ +┌─────────────────────────────────┐ +│ 수주관리 화면 설정 │ +│ │ +│ [탭1: 테이블 연결] │ +│ [탭2: 조인 설정] │ +│ [탭3: 필터 설정] │ +│ [탭4: 필드-컬럼 매칭] │ +│ [탭5: 저장 테이블] │ +│ │ +│ [저장] [취소] │ +└─────────────────────────────────┘ +``` + +#### 방안 B: 기능별 분리 모달 + +우클릭 컨텍스트 메뉴로 기능 선택 → 해당 기능 모달 열림 + +``` +[화면 노드] 우클릭 + ↓ +┌─────────────────┐ +│ 테이블 연결 설정 │ +│ 조인 설정 │ +│ 필터 설정 │ +│ 필드-컬럼 매칭 │ +│ 저장 테이블 설정 │ +└─────────────────┘ +``` + +#### 방안 C: 사이드 패널 + +노드 클릭 시 **오른쪽 패널**에 설정 UI 표시 (모달 없이) + +### 현재 상태 + +| 항목 | 상태 | +|------|------| +| 노드 플로워 시각화 | ✅ 완료 (읽기 전용) | +| DB 테이블 | ✅ 있음 (`screen_field_joins`, `screen_data_flows` 등) | +| 백엔드 API | ✅ 있음 (CRUD) | +| 패널 UI | ✅ 있음 (`FieldJoinPanel`, `DataFlowPanel`) | +| **노드에서 직접 설정** | ✅ **구현 완료** (방안 A) | + +--- + +## 노드에서 직접 설정 기능 (방안 A: 통합 설정 모달) + +### 구현 완료 (2026-01-09) + +노드 더블클릭 시 통합 설정 모달이 열리며, 4개 탭으로 다양한 설정을 수행할 수 있습니다. + +#### 사용법 + +1. **화면 노드** 또는 **테이블 노드**를 **더블클릭** +2. 통합 설정 모달이 열림 +3. 탭 선택하여 설정 +4. 저장 후 시각화 자동 새로고침 + +#### 탭 구성 + +| 탭 | 기능 | 설명 | +|----|------|------| +| 테이블 연결 | 화면-테이블 관계 설정 | 메인/서브/조회/저장 테이블 지정, CRUD 권한 설정 | +| 조인 설정 | FK-PK 조인 관계 설정 | 저장 테이블의 FK 컬럼 ↔ 조인 테이블의 PK 컬럼 매핑, 표시 컬럼 지정 | +| 데이터 흐름 | 화면 간 데이터 이동 설정 | 소스 화면 → 타겟 화면, 단방향/양방향 흐름 설정 | +| 필드 매핑 | 테이블 컬럼 정보 조회 | 현재 테이블의 컬럼 목록, 데이터 타입, 웹 타입 확인 | + +#### 구현 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/components/screen/NodeSettingModal.tsx` | **새로 생성** - 통합 설정 모달 컴포넌트 | +| `frontend/components/screen/ScreenRelationFlow.tsx` | 노드 더블클릭 이벤트 핸들러 추가 | + +#### 주요 코드 변경 + +**NodeSettingModal.tsx (신규)** +- 4개 탭 컴포넌트 내장 (TableRelationTab, JoinSettingTab, DataFlowTab, FieldMappingTab) +- 기존 API 활용: `getTableRelations`, `getFieldJoins`, `getDataFlows` +- CRUD 연동: `createFieldJoin`, `updateFieldJoin`, `deleteFieldJoin` 등 +- 저장 후 부모 컴포넌트 새로고침 콜백 (`onRefresh`) + +**ScreenRelationFlow.tsx (수정)** +```typescript +// 노드 더블클릭 이벤트 핸들러 추가 +const handleNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => { + // 화면/테이블 노드 판별 후 모달 오픈 + if (node.id.startsWith("screen-")) { + // 화면 노드 처리 + } else if (node.id.startsWith("table-")) { + // 테이블 노드 처리 + } + setIsSettingModalOpen(true); +}, [screenTableMap, screenSubTableMap]); + +// ReactFlow에 이벤트 연결 + + +// 모달 렌더링 + +``` + +#### 시각화 새로고침 메커니즘 + +```typescript +// 강제 새로고침용 키 +const [refreshKey, setRefreshKey] = useState(0); + +// 새로고침 핸들러 +const handleRefreshVisualization = useCallback(() => { + setRefreshKey(prev => prev + 1); +}, []); + +// useEffect 의존성에 refreshKey 추가 +useEffect(() => { + // 데이터 로드 로직 +}, [screen, selectedGroup, ..., refreshKey]); +``` + +--- + +### 주요 파일 경로 + +``` +backend-node/src/ +├── controllers/screenGroupController.ts # 화면 그룹 API +├── routes/screenGroupRoutes.ts # 라우트 정의 + +frontend/ +├── app/(main)/admin/screenMng/screenMngList/page.tsx # 메인 페이지 +├── components/screen/ +│ ├── ScreenGroupTreeView.tsx # 트리 뷰 (그룹/화면 표시) +│ ├── ScreenGroupModal.tsx # 그룹 추가/수정 모달 +│ ├── ScreenRelationFlow.tsx # React Flow 시각화 + 더블클릭 이벤트 +│ ├── ScreenNode.tsx # 노드 컴포넌트 +│ ├── NodeSettingModal.tsx # **신규** - 통합 설정 모달 +│ └── panels/ +│ ├── FieldJoinPanel.tsx # 필드 조인 설정 UI (개별 패널) +│ └── DataFlowPanel.tsx # 데이터 흐름 설정 UI (개별 패널) +└── lib/api/screenGroup.ts # API 클라이언트 +``` + +--- + +## 향후 개선 사항 + +### 필드 매핑 탭 고도화 + +현재 필드 매핑 탭은 테이블 컬럼 정보를 조회만 가능합니다. 향후 다음 기능 추가 가능: + +1. **컬럼-컴포넌트 바인딩 설정**: 화면 컴포넌트와 DB 컬럼 직접 연결 +2. **드래그 앤 드롭**: 시각적 매핑 UI +3. **자동 매핑 추천**: 컬럼명 기반 자동 매핑 제안 + +### 관계 시각화 연동 + +설정 저장 후 시각화에 즉시 반영되지만, 다음 개선 가능: + +1. **실시간 프리뷰**: 저장 전 미리보기 +2. **관계 유형별 색상 커스터마이징** +3. **관계 라벨 표시 옵션** + +--- + +## 화면 설정 모달 개선 (2026-01-12) + +### 개요 + +화면 노드 우클릭 시 열리는 설정 모달을 대폭 개선했습니다. + +### 주요 변경 사항 + +#### 1. 테이블 정보 시각화 개선 + +| 항목 | 변경 내용 | +|------|----------| +| 메인 테이블 | 아코디언 형식으로 모든 컬럼 표시 | +| 필터 테이블 | 아코디언 형식 + 필터/조인 키 색상 구분 | +| 사용 중 컬럼 | 파란색 배경 + "필드" 배지로 강조 | + +#### 2. 화면 프리뷰 상시 표시 + +- 모달 레이아웃: 좌측 40% (탭) / 우측 60% (프리뷰) +- 탭 전환해도 프리뷰 항상 표시 + +#### 3. 줌/드래그 기능 (react-zoom-pan-pinch 라이브러리) + +```bash +npm install react-zoom-pan-pinch +``` + +| 기능 | 동작 | +|------|------| +| 휠 스크롤 | 마우스 포인터 기준 확대/축소 (20%~300%) | +| 드래그 | 화면 이동 | +| 클릭 | iframe 내부 버튼/목록 상호작용 | + +#### 4. 프리뷰 company_code 전달 문제 해결 + +| 문제 | 해결 | +|------|------| +| 최고 관리자로 다른 회사 프리뷰 불가 | `companyCodeOverride` 파라미터 도입 | +| URL 파라미터 무시됨 | 백엔드에서 admin 전용 오버라이드 처리 | + +### 관련 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `ScreenSettingModal.tsx` | 전체 UI 개선, 줌/드래그 기능 | +| `entityJoin.ts` | `companyCodeOverride` 파라미터 추가 | +| `SplitPanelLayoutComponent.tsx` | `companyCode` prop 추가 | +| `entityJoinController.ts` | `companyCodeOverride` 처리 로직 | + +### 상세 문서 + +- [화면설정모달_개선_완료_보고서.md](./화면설정모달_개선_완료_보고서.md) + +--- + +## 관련 문서 + +- [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) +- [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc) +- [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc) + diff --git a/docs/화면설정모달_개선_완료_보고서.md b/docs/화면설정모달_개선_완료_보고서.md new file mode 100644 index 00000000..493c3f37 --- /dev/null +++ b/docs/화면설정모달_개선_완료_보고서.md @@ -0,0 +1,535 @@ +# 화면 설정 모달 개선 완료 보고서 + +## 개요 +화면 관리에서 화면 노드 우클릭 시 열리는 설정 모달을 대폭 개선하여, 테이블 정보 시각화, 필드 매핑 확인, 컬럼 변경/추가/제거 기능, 조인 설정 기능, 실시간 프리뷰 기능을 강화했습니다. + +## 주요 개선 사항 + +### 1. 화면 개요 탭 통합 개선 + +#### 1.1 필드 매핑 탭 → 개요 탭 통합 +- 기존 "필드 매핑" 탭 제거 +- 필드 매핑 정보를 개요 탭의 메인/필터 테이블 아코디언에 통합 표시 +- 더 직관적이고 간결한 UI 제공 + +#### 1.2 메인 테이블 아코디언 +- 메인 테이블(예: `customer_mng`)을 아코디언 형식으로 표시 +- 클릭 시 테이블의 모든 컬럼 정보 표시 +- **1열 레이아웃**: 컬럼 정보를 세로로 배치 +- 화면에서 사용 중인 컬럼은 **파란색 배경 + "필드" 배지**로 강조 +- **컬럼 정렬**: + - 사용중인 필드가 상단에 표시 + - 화면에 표시되는 순서대로 정렬 (y좌표 기준) + - 미사용 컬럼은 하단에 표시 + +#### 1.3 필터 테이블 아코디언 +- 필터 테이블(예: `customer_item_mapping`)을 아코디언 형식으로 표시 +- 클릭 시 테이블의 모든 컬럼 정보 표시 +- 컬럼별 색상 구분: + - **파란색**: 화면에서 사용 중인 컬럼 (필드) + - **보라색**: 필터 키 컬럼 (WHERE 절에 사용) + - **주황색**: 조인 키 컬럼 (JOIN 조건에 사용) +- **다중 배지 표시**: 컬럼이 필드이면서 조인/필터 키인 경우 배지 동시 표시 +- 필터 연결 정보 표시 (예: `→ customer_mng`) + +#### 1.4 컬럼 레이아웃 순서 +- **순서**: `컬럼명 | 배지 | 데이터타입` +- 예: `거래처 코드` `[필드]` `character varying` +- 데이터타입은 오른쪽 정렬 + +#### 1.5 클릭 스타일 개선 +- **테두리 제거**: ring-2, ring-offset 등 제거 +- **강조 색상 연하게**: + - 선택됨: `bg-blue-100 border-blue-300` + - 미선택: `bg-blue-50 border-blue-200` +- 더 부드러운 시각적 피드백 + +#### 1.6 패널 높이 동기화 +- 왼쪽(컬럼 목록)과 오른쪽(설정 패널) 동일한 `max-h-[350px]` 적용 +- `overflow-y-auto`로 스크롤 처리 +- `items-stretch`로 양쪽 패널 높이 동기화 + +### 2. 컬럼 변경 기능 + +#### 2.1 인라인 컬럼 편집 +- 사용중인 필드(파란색 배경)를 클릭하면 우측에 "컬럼 설정" 패널 표시 +- 패널 정보: + - **화면 필드**: 컬럼 한글명 표시 (예: "거래처 코드") + - **현재 컬럼**: 영문 컬럼명 표시 (예: `customer_code`) + - **컬럼 변경**: 드롭다운으로 다른 컬럼 선택 +- 검색 기능으로 컬럼 빠르게 찾기 + +#### 2.2 실시간 반영 +- 컬럼 변경 후 **페이지 새로고침 없이** 실시간 반영 +- `onRefresh` 콜백으로 데이터 리로드 + iframe 새로고침 +- 더 빠른 사용자 경험 + +#### 2.3 변경사항 저장 +- `screenApi.saveLayout()` 사용하여 **화면 디자이너와 동일한 테이블에 저장** +- 저장 위치: + - `componentConfig.leftPanel.columns` (분할 패널) + - `componentConfig.rightPanel.columns` (분할 패널) + - `usedColumns` 배열 + - `bindField` 필드 + - `fieldMapping` 배열 + +### 3. 필드 추가/제거 기능 (신규) + +#### 3.1 필드 추가 +- 비필드 컬럼(회색/흰색 배경) 클릭 +- "컬럼 설정" 패널에 컬럼 정보 표시 +- **"필드로 추가"** 버튼 클릭 → 해당 컬럼이 화면 필드로 추가됨 +- 버튼 스타일: `text-blue-600 border-blue-300 hover:bg-blue-50` (파란색 테두리) + +#### 3.2 필드 제거 +- 기존 필드(파란색 배경) 클릭 +- "컬럼 설정" 패널에 필드 정보 표시 +- **"필드에서 제거"** 버튼 클릭 → 해당 필드가 화면에서 제거됨 +- 버튼 스타일: `text-red-600 border-red-300 hover:bg-red-50` (빨간색 테두리) + +#### 3.3 저장 로직 +```typescript +// 필드 추가: 배열에 새 컬럼 추가 +if (isAddingField) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: [...leftColumns, { name: newColumn, columnName: newColumn }], + }, + }, + }; +} + +// 필드 제거: 배열에서 해당 컬럼 제거 +if (isRemovingField) { + const filteredColumns = leftColumns.filter((_, i) => i !== columnIdx); + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: filteredColumns, + }, + }, + }; +} +``` + +#### 3.4 적용 범위 +- 메인 테이블 아코디언: 필드 추가/제거 가능 +- 필터 테이블 아코디언: 필드 추가/제거 가능 +- `usedColumns`, `componentConfig.usedColumns`, `componentConfig.columns`, `leftPanel.columns`, `rightPanel.columns` 모두 지원 + +### 4. 화면 프리뷰 상시 표시 + +#### 4.1 레이아웃 변경 +- 기존: 탭으로 프리뷰 전환 +- 개선: **모달 우측에 프리뷰 상시 표시** +- 모달 크기 확대 (1600px 최대 너비) +- 좌측 40% (탭 콘텐츠) / 우측 60% (프리뷰) + +#### 4.2 줌/드래그/클릭 기능 (react-zoom-pan-pinch 라이브러리) +- **휠 스크롤**: 마우스 포인터 위치 기준 확대/축소 (20% ~ 300%) +- **드래그**: 마우스 왼쪽 버튼으로 화면 이동 (5px 이상 이동 시) +- **클릭**: iframe 내부 요소 정상 클릭 가능 + - 버튼, 셀렉트박스, 체크박스, 테이블 행 클릭 + - 인풋박스/텍스트박스 포커스 및 입력 + - X버튼(닫기) 등 SVG 아이콘 버튼 클릭 + +#### 4.3 클릭 좌표 보정 시스템 +- 줌 상태에서도 정확한 클릭 위치 계산 +- `designWidth / rect.width` 비율로 좌표 변환 +- 오버레이 방식으로 드래그와 클릭 분리 처리 + +### 5. 필드+조인 컬럼 스타일 개선 + +#### 5.1 다중 역할 컬럼 표시 +- 컬럼이 **필드이면서 조인 키**인 경우: + - **파란색 배경** (필드 기준) + - **왼쪽에 주황색 세로 선** (`border-l-4 border-l-orange-500`) + - 배지: `조인` `필드` 동시 표시 +- 컬럼이 **필드이면서 필터 키**인 경우: + - **파란색 배경** (필드 기준) + - **왼쪽에 보라색 세로 선** (`border-l-4 border-l-purple-400`) + - 배지: `필터` `필드` 동시 표시 + +#### 5.2 조인 컬럼도 필드로 인식 +- `filterTableColumnMappings` 생성 시 조인 컬럼(`ft.joinColumnRefs`)도 포함 +- 조인 테이블 데이터를 화면에서 보여주므로 필드로 간주 + +#### 5.3 컬럼 설정 패널 - 조인 정보 표시 +- 조인 키 클릭 시 패널에 조인 정보 표시: + - **대상 테이블**: item_info (실제 참조 테이블) + - **연결 컬럼**: item_number (참조 컬럼) + +### 6. 조인 관계 설정/수정 기능 + +#### 6.1 기능 설명 +- 컬럼 설정 패널에서 **조인 관계 직접 수정** 가능 +- **모든 컬럼에서 조인 설정 가능** (기존 조인 키가 아닌 컬럼도 포함) +- 테이블 타입 관리(`column_labels` 테이블)와 동일한 저장 위치 사용 + +#### 6.2 저장 테이블 +``` +column_labels 테이블: +├── reference_table (참조 테이블명) +├── reference_column (참조 컬럼 - 보통 PK) +└── display_column (화면에 표시할 컬럼) +``` + +#### 6.3 구현된 UI +1. **컬럼 클릭** → 컬럼 설정 패널 표시 +2. **"조인" 섹션 확인**: + - 조인 설정 있음: "편집" 버튼 + - 조인 설정 없음: "추가" 버튼 +3. **드롭다운으로 설정** (모두 검색 가능): + - 대상 테이블: 전체 테이블 목록에서 검색/선택 + - 연결 컬럼 (PK): 선택한 테이블의 컬럼 중 검색/선택 + - 표시 컬럼: 화면에 표시할 컬럼 검색/선택 +4. **저장 버튼** → `column_labels` 테이블에 저장 +5. **취소 버튼** → 편집 취소 + +#### 6.4 검색 가능한 드롭다운 +- Popover + Command 컴포넌트 사용 +- 실시간 텍스트 검색 지원 +- 대상 테이블, 연결 컬럼, 표시 컬럼 모두 검색 가능 + +#### 6.5 API 연동 +- **테이블 목록 조회**: `tableManagementApi.getTableList()` +- **컬럼 목록 조회**: `tableManagementApi.getColumnList(tableName)` +- **저장**: `tableManagementApi.updateColumnSettings(tableName, columnName, settings)` + +#### 6.6 메인 테이블에도 조인 설정 적용 +- 메인 테이블 아코디언에서도 조인 설정 가능 +- 필터 테이블과 동일한 UI/기능 제공 + +#### 6.7 조인 데이터 소스 수정 +- 기존 조인 키 클릭 시 `joinRef.refTable` 값을 사용 +- 예: `품목 ID` → `item_info` (실제 참조 테이블) +- `mainTable` 대신 `joinRef.refTable` 사용으로 정확한 테이블 표시 + +### 7. 배지 순서 및 스타일 +- **배지 순서**: `필터` → `조인` → `필드` (필드가 맨 뒤) +- **조인 배지**: 주황색 배경 (`bg-orange-200 text-orange-700`) +- **필터 배지**: 보라색 배경 (`bg-purple-200 text-purple-700`) +- **필드 배지**: 파란색 배경 (`bg-blue-500 text-white`) + +### 8. 컴포넌트 통합 리팩토링 + +#### 8.1 `TableColumnAccordion` 통합 컴포넌트 +- 기존 `MainTableAccordion`과 `FilterTableAccordion`을 하나의 컴포넌트로 통합 +- `tableType` prop으로 "main" 또는 "filter" 구분 +- 코드 중복 제거 및 유지보수성 향상 + +#### 8.2 Props 구조 +```typescript +interface TableColumnAccordionProps { + tableName: string; + tableLabel?: string; + tableType: "main" | "filter"; + columnMappings?: ColumnMapping[]; + onColumnChange?: (fieldLabel: string, oldColumn: string, newColumn: string) => void; + onColumnReorder?: (newOrder: string[]) => void; + onJoinSettingSaved?: () => void; + // 필터 테이블 전용 props + mainTable?: string; + filterKeyMapping?: FilterKeyMapping; + joinColumnRefs?: JoinColumnRef[]; +} +``` + +#### 8.3 동적 테마 적용 +- 메인 테이블: 파란색 테마 (`blue`) +- 필터 테이블: 보라색 테마 (`purple`) +- `themeColor`, `themeIcon`, `themeBadge` 변수로 동적 스타일 적용 + +### 9. 드래그 앤 드롭 컬럼 순서 변경 + +#### 9.1 기능 설명 +- 사용 중인 컬럼(필드)을 드래그하여 순서 변경 가능 +- 드래그 중에는 시각적으로만 순서 변경, **드롭 시에만 저장** +- 드래그 취소(영역 밖으로 나간 경우) 시 원래 순서로 복원 + +#### 9.2 드래그 상태 관리 +```typescript +// 드래그 상태 +const [draggedIndex, setDraggedIndex] = useState(null); +const [localColumnOrder, setLocalColumnOrder] = useState(null); +``` + +#### 9.3 드래그 핸들러 +```typescript +// 드래그 시작: 현재 순서를 로컬 상태로 저장 +const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + const usedColumns = sortedColumns.filter(col => columnMappingMap.has(col.columnName.toLowerCase())); + setLocalColumnOrder(usedColumns.map(col => col.columnName)); +}; + +// 드래그 중: 로컬 순서만 변경 (저장하지 않음) +const handleDragOver = (e: React.DragEvent, hoverIndex: number) => { + if (draggedIndex === null || draggedIndex === hoverIndex || !localColumnOrder) return; + const newOrder = [...localColumnOrder]; + const draggedItem = newOrder[draggedIndex]; + newOrder.splice(draggedIndex, 1); + newOrder.splice(hoverIndex, 0, draggedItem); + setDraggedIndex(hoverIndex); + setLocalColumnOrder(newOrder); +}; + +// 드롭: 최종 순서로 저장 +const handleDrop = (e: React.DragEvent) => { + if (localColumnOrder && onColumnReorder) { + onColumnReorder(localColumnOrder); + } + setDraggedIndex(null); + setLocalColumnOrder(null); +}; + +// 드래그 취소 +const handleDragEnd = () => { + setDraggedIndex(null); + setLocalColumnOrder(null); +}; +``` + +#### 9.4 시각적 피드백 +- 드래그 가능한 컬럼: `cursor-grab active:cursor-grabbing` +- 드래그 중인 컬럼: `opacity-50 scale-95` +- 드래그 중 실시간 순서 변경 표시 + +#### 9.5 저장 로직 (`handleColumnReorder`) +```typescript +const handleColumnReorder = async (tableType: "main" | "filter", newOrder: string[]) => { + const currentLayout = await screenApi.getLayout(screenId); + + const updatedComponents = currentLayout.components.map((comp: any) => { + // leftPanel.columns 순서 변경 + if (comp.componentConfig?.leftPanel?.columns) { + const leftColumns = comp.componentConfig.leftPanel.columns; + const reorderedColumns = newOrder.map(colName => + leftColumns.find((col: any) => col.name?.toLowerCase() === colName.toLowerCase()) + ).filter(Boolean); + const remainingColumns = leftColumns.filter((col: any) => + !newOrder.some(n => n.toLowerCase() === col.name?.toLowerCase()) + ); + + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: [...reorderedColumns, ...remainingColumns], + }, + }, + }; + } + return comp; + }); + + await screenApi.saveLayout(screenId, { ...currentLayout, components: updatedComponents }); + onRefresh?.(); +}; +``` + +#### 9.6 지원 범위 +- 메인 테이블: `onColumnReorder={(newOrder) => handleColumnReorder("main", newOrder)}` +- 필터 테이블: `onColumnReorder={(newOrder) => handleColumnReorder("filter", newOrder)}` +- 지원 배열: + - `componentConfig.leftPanel.columns` + - `componentConfig.rightPanel.columns` + - `componentConfig.usedColumns` + - `componentConfig.columns` + +## 기술 스택 + +### 신규 의존성 +```bash +npm install react-zoom-pan-pinch +``` + +### 사용된 컴포넌트 +- `TransformWrapper`, `TransformComponent` - 줌/드래그 기능 +- `Accordion`, `AccordionContent`, `AccordionItem`, `AccordionTrigger` - 아코디언 UI +- `Popover`, `PopoverTrigger`, `PopoverContent` - 드롭다운 컨테이너 +- `Command`, `CommandInput`, `CommandList`, `CommandItem`, `CommandEmpty` - 검색 가능한 선택 UI +- `tableManagementApi.getColumnList()` - 테이블 컬럼 정보 조회 +- `tableManagementApi.getTableList()` - 테이블 목록 조회 +- `tableManagementApi.updateColumnSettings()` - 조인 설정 저장 +- `screenApi.saveLayout()` - 레이아웃 저장 +- `screenApi.getLayout()` - 레이아웃 조회 + +### 핵심 로직 + +#### 컬럼 변경/추가/제거 +```typescript +const handleColumnChange = async (fieldLabel: string, oldColumn: string, newColumn: string) => { + const isAddingField = fieldLabel === "__NEW_FIELD__"; + const isRemovingField = newColumn === "__REMOVE_FIELD__"; + + const currentLayout = await screenApi.getLayout(screenId); + + const updatedComponents = currentLayout.components.map((comp: any) => { + if (comp.componentConfig?.leftPanel?.columns) { + const leftColumns = comp.componentConfig.leftPanel.columns; + + // 필드 추가 + if (isAddingField) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: [...leftColumns, { name: newColumn, columnName: newColumn }], + }, + }, + }; + } + + // 필드 제거 + const columnIdx = leftColumns.findIndex((col: any) => ...); + if (columnIdx !== -1 && isRemovingField) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: leftColumns.filter((_, i) => i !== columnIdx), + }, + }, + }; + } + + // 컬럼 변경 + if (columnIdx !== -1) { + return { + ...comp, + componentConfig: { + ...comp.componentConfig, + leftPanel: { + ...comp.componentConfig.leftPanel, + columns: leftColumns.map((col, i) => + i === columnIdx ? { ...col, name: newColumn } : col + ), + }, + }, + }; + } + } + return comp; + }); + + await screenApi.saveLayout(screenId, { ...currentLayout, components: updatedComponents }); + onRefresh?.(); +}; +``` + +#### 조인 설정 편집기 (JoinSettingEditor) +```tsx + +``` + +## 파일 변경 목록 + +| 파일 | 변경 내용 | +|------|----------| +| `frontend/components/screen/ScreenSettingModal.tsx` | 전체 UI 개선, 줌/드래그 기능, 컬럼 변경/추가/제거 기능, 조인 설정 기능, 필드 매핑 통합, 실시간 반영 | +| `frontend/components/screen/ScreenRelationFlow.tsx` | `filterKeyMapping`, `joinColumnRefs` 데이터 전달 | +| `frontend/lib/api/entityJoin.ts` | `companyCodeOverride` 파라미터 추가 | +| `frontend/lib/api/screen.ts` | `saveLayout`, `getLayout` API 사용 | +| `frontend/lib/api/tableManagement.ts` | `getTableList`, `getColumnList`, `updateColumnSettings` API | +| `frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx` | `companyCode` prop 추가 | +| `backend-node/src/controllers/entityJoinController.ts` | `companyCodeOverride` 처리 로직 추가 | + +## 사용 방법 + +### 화면 설정 모달 열기 +1. 화면 관리 페이지에서 화면 그룹 선택 +2. 화면 노드 우클릭 → 컨텍스트 메뉴 표시 +3. "화면 설정" 선택 → 모달 열림 +4. 좌측 탭에서 정보 확인/수정, 우측에서 실시간 프리뷰 + +### 프리뷰 영역 조작 +- **휠 스크롤**: 확대/축소 (5% 단위) +- **마우스 드래그**: 화면 이동 (5px 이상 움직여야 드래그로 인식) +- **짧은 클릭**: iframe 내부 요소 클릭 + +### 컬럼 변경 +1. 메인/필터 테이블 아코디언 펼치기 +2. 파란색 배경의 "필드" 컬럼 클릭 +3. 우측 "컬럼 설정" 패널 확인 +4. "컬럼 변경" 드롭다운에서 새 컬럼 선택 +5. **실시간 반영** (페이지 새로고침 없음) + +### 필드 추가 +1. 메인/필터 테이블 아코디언 펼치기 +2. 회색/흰색 배경의 비필드 컬럼 클릭 +3. 우측 패널에서 **"필드로 추가"** 버튼 클릭 +4. 해당 컬럼이 화면 필드로 추가됨 + +### 필드 제거 +1. 메인/필터 테이블 아코디언 펼치기 +2. 파란색 배경의 필드 컬럼 클릭 +3. 우측 패널에서 **"필드에서 제거"** 버튼 클릭 +4. 해당 필드가 화면에서 제거됨 + +### 조인 설정 추가/편집 +1. 메인/필터 테이블 아코디언 펼치기 +2. 아무 컬럼 클릭 (조인 키가 아니어도 됨) +3. 우측 패널의 "조인" 섹션에서: + - 조인 없음: **"추가"** 버튼 클릭 + - 조인 있음: **"편집"** 버튼 클릭 +4. 대상 테이블 선택 (검색 가능) +5. 연결 컬럼 (PK) 선택 (검색 가능) +6. 표시 컬럼 선택 (검색 가능) +7. **"저장"** 버튼 클릭 + +### 컬럼 순서 변경 (드래그 앤 드롭) +1. 메인/필터 테이블 아코디언 펼치기 +2. 파란색 배경의 "필드" 컬럼을 드래그 시작 +3. 원하는 위치로 드래그하여 이동 (실시간으로 순서 변경 표시) +4. 마우스를 놓으면 (드롭) 순서가 저장됨 +5. 드래그 취소하려면 컬럼 영역 밖으로 드래그 + +**참고:** +- 사용 중인 필드만 드래그 가능 (파란색 배경) +- 미사용 컬럼은 드래그 불가 +- 드래그 중에는 저장되지 않고, 드롭 시에만 저장됨 + +--- + +## 완료일 +2026-01-13 + +## 변경 이력 +- 2026-01-12: 최초 작성 (줌/드래그/클릭, company_code 전달) +- 2026-01-12: 컬럼 변경 기능 추가, 필드 매핑 통합, UI 개선 (1열 레이아웃, 배지 변경) +- 2026-01-12: 실시간 반영 구현 (reload 제거), 레이아웃 순서 변경, 스타일 개선 +- 2026-01-12: 필드+조인 컬럼 스타일 개선 (파란배경 + 왼쪽 주황선), 조인 정보 패널 표시 +- 2026-01-12: 조인 관계 설정/수정 기능 구현 완료 (column_labels 테이블 저장) +- 2026-01-13: 필드 추가/제거 기능 구현 +- 2026-01-13: 검색 가능한 조인 설정 드롭다운 (Command 컴포넌트) +- 2026-01-13: 모든 컬럼에서 조인 설정 가능 (범용성 패치) +- 2026-01-13: 메인 테이블에도 조인 설정 기능 추가 +- 2026-01-13: 조인 라인 색상 주황색으로 변경 (`border-l-orange-500`) +- 2026-01-13: 조인 데이터 소스 수정 (`joinRef.refTable` 사용) +- 2026-01-13: 패널 높이 동기화 (`max-h-[350px]`, `items-stretch`) +- 2026-01-13: `MainTableAccordion`과 `FilterTableAccordion`을 `TableColumnAccordion`으로 통합 +- 2026-01-13: 드래그 앤 드롭 컬럼 순서 변경 기능 구현 +- 2026-01-13: 드래그 중에는 로컬 상태만 변경, 드롭 시에만 저장하도록 최적화 diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 723a8130..443f9dc8 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -1,68 +1,93 @@ "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 [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); + const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = 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); + setSelectedGroup(null); // 그룹 선택 해제 + }; + + // 화면 디자인 핸들러 + 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 +97,116 @@ 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" + /> +
+
+ {/* 트리 뷰 */} +
+ { + setSelectedGroup(group); + setSelectedScreen(null); // 화면 선택 해제 + setFocusedScreenIdInGroup(null); // 포커스 초기화 + }} + onScreenSelectInGroup={(group, screenId) => { + // 그룹 내 화면 클릭 시 + const isNewGroup = selectedGroup?.id !== group.id; + + if (isNewGroup) { + // 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지) + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); + }} + /> +
+
+ + {/* 오른쪽: 관계 시각화 (React Flow) */} +
+ +
+
+ ) : ( + // 테이블 뷰 (기존 ScreenList 사용) +
+ +
+ )} + + {/* 화면 생성 모달 */} + setIsCreateOpen(false)} + onSuccess={() => { + setIsCreateOpen(false); + loadScreens(); + }} + /> + {/* Scroll to Top 버튼 */}
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9e92bf2b..4d7b8e7c 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -32,9 +32,18 @@ function ScreenViewPage() { // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; + + // URL 쿼리에서 프리뷰용 company_code 가져오기 + const previewCompanyCode = searchParams.get("company_code"); + + // 프리뷰 모드 감지 (iframe에서 로드될 때) + const isPreviewMode = searchParams.get("preview") === "true"; // 🆕 현재 로그인한 사용자 정보 - const { user, userName, companyCode } = useAuth(); + const { user, userName, companyCode: authCompanyCode } = useAuth(); + + // 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용 + const companyCode = previewCompanyCode || authCompanyCode; // 🆕 모바일 환경 감지 const { isMobile } = useResponsive(); @@ -233,27 +242,40 @@ function ScreenViewPage() { const designWidth = layout?.screenResolution?.width || 1200; const designHeight = layout?.screenResolution?.height || 800; - // 컨테이너의 실제 크기 - const containerWidth = containerRef.current.offsetWidth; - const containerHeight = containerRef.current.offsetHeight; + // 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용) + let containerWidth: number; + let containerHeight: number; + + if (isPreviewMode) { + // iframe에서는 window 크기를 직접 사용 + containerWidth = window.innerWidth; + containerHeight = window.innerHeight; + } else { + containerWidth = containerRef.current.offsetWidth; + containerHeight = containerRef.current.offsetHeight; + } - // 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8) - const MARGIN_X = 32; - const availableWidth = containerWidth - MARGIN_X; - - // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정) - const newScale = availableWidth / designWidth; + let newScale: number; + + if (isPreviewMode) { + // 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이) + const scaleX = containerWidth / designWidth; + const scaleY = containerHeight / designHeight; + newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율 + } else { + // 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정) + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; + newScale = availableWidth / designWidth; + } // console.log("📐 스케일 계산:", { // containerWidth, // containerHeight, - // MARGIN_X, - // availableWidth, // designWidth, // designHeight, // finalScale: newScale, - // "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`, - // "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`, + // isPreviewMode, // }); setScale(newScale); @@ -272,7 +294,7 @@ function ScreenViewPage() { return () => { clearTimeout(timer); }; - }, [layout, isMobile]); + }, [layout, isMobile, isPreviewMode]); if (loading) { return ( @@ -310,7 +332,7 @@ function ScreenViewPage() { -
+
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && (
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2fbbe7c5..1614c9b8 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -463,7 +463,8 @@ select { left: 0; right: 0; bottom: 0; - background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + background: + repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%); pointer-events: none; @@ -471,18 +472,24 @@ select { } .pop-light .pop-bg-pattern::before { - background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + background: + repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%); } /* POP 글로우 효과 */ .pop-glow-cyan { - box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3); + box-shadow: + 0 0 20px rgba(0, 212, 255, 0.5), + 0 0 40px rgba(0, 212, 255, 0.3); } .pop-glow-cyan-strong { - box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3); + box-shadow: + 0 0 10px rgba(0, 212, 255, 0.8), + 0 0 30px rgba(0, 212, 255, 0.5), + 0 0 50px rgba(0, 212, 255, 0.3); } .pop-glow-success { @@ -504,7 +511,9 @@ select { box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); } 50% { - box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4); + box-shadow: + 0 0 20px rgba(0, 212, 255, 0.8), + 0 0 30px rgba(0, 212, 255, 0.4); } } @@ -610,4 +619,18 @@ select { animation: marching-ants-v 0.4s linear infinite; } +/* ===== 저장 테이블 막대기 애니메이션 ===== */ +@keyframes saveBarDrop { + 0% { + transform: scaleY(0); + transform-origin: top; + opacity: 0; + } + 100% { + transform: scaleY(1); + transform-origin: top; + opacity: 1; + } +} + /* ===== End of Global Styles ===== */ diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 236071ac..e3e8d920 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -302,6 +302,9 @@ function AppLayoutInner({ children }: AppLayoutProps) { // 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin"; + // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용) + const isPreviewMode = searchParams.get("preview") === "true"; + // 현재 모드에 따라 표시할 메뉴 결정 // 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시 const currentMenus = isAdminMode ? adminMenus : userMenus; @@ -458,6 +461,15 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } + // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 + if (isPreviewMode) { + return ( +
+ {children} +
+ ); + } + // UI 변환된 메뉴 데이터 const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); diff --git a/frontend/components/screen/NodeSettingModal.tsx b/frontend/components/screen/NodeSettingModal.tsx new file mode 100644 index 00000000..173e5664 --- /dev/null +++ b/frontend/components/screen/NodeSettingModal.tsx @@ -0,0 +1,1889 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { + Database, + Link2, + GitBranch, + Columns3, + Save, + Plus, + Pencil, + Trash2, + RefreshCw, + Loader2, + Check, + ChevronsUpDown, +} from "lucide-react"; +import { + getTableRelations, + createTableRelation, + updateTableRelation, + deleteTableRelation, + getFieldJoins, + createFieldJoin, + updateFieldJoin, + deleteFieldJoin, + getDataFlows, + createDataFlow, + updateDataFlow, + deleteDataFlow, + FieldJoin, + DataFlow, + TableRelation, +} from "@/lib/api/screenGroup"; +import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement"; + +// ============================================================ +// 타입 정의 +// ============================================================ + +// 기존 설정 정보 (화면 디자이너에서 추출) +interface ExistingConfig { + joinColumnRefs?: Array<{ + column: string; + refTable: string; + refTableLabel?: string; + refColumn: string; + }>; + filterColumns?: string[]; + fieldMappings?: Array<{ + targetField: string; + sourceField: string; + sourceTable?: string; + sourceDisplayName?: string; + }>; + referencedBy?: Array<{ + fromTable: string; + fromTableLabel?: string; + fromColumn: string; + toColumn: string; + toColumnLabel?: string; + relationType: string; + }>; + columns?: Array<{ + name: string; + originalName?: string; + type: string; + isPrimaryKey?: boolean; + isForeignKey?: boolean; + }>; + // 화면 노드용 테이블 정보 + mainTable?: string; + filterTables?: Array<{ + tableName: string; + tableLabel: string; + filterColumns: string[]; + joinColumnRefs: Array<{ + column: string; + refTable: string; + refTableLabel?: string; + refColumn: string; + }>; + }>; +} + +interface NodeSettingModalProps { + isOpen: boolean; + onClose: () => void; + // 노드 정보 + nodeType: "screen" | "table"; + nodeId: string; // 노드 ID (예: screen-1, table-sales_order_mng) + screenId: number; + screenName: string; + tableName?: string; // 테이블 노드인 경우 + tableLabel?: string; + // 그룹 정보 (데이터 흐름 설정에 필요) + groupId?: number; + groupScreens?: Array<{ screen_id: number; screen_name: string }>; + // 기존 설정 정보 (화면 디자이너에서 추출한 조인/필터 정보) + existingConfig?: ExistingConfig; + // 새로고침 콜백 + onRefresh?: () => void; +} + +// 탭 ID +type TabId = "table-relation" | "join-setting" | "data-flow" | "field-mapping"; + +// ============================================================ +// 검색 가능한 셀렉트 컴포넌트 +// ============================================================ + +interface SearchableSelectProps { + value: string; + onValueChange: (value: string) => void; + options: Array<{ value: string; label: string; description?: string }>; + placeholder?: string; + searchPlaceholder?: string; + emptyText?: string; + disabled?: boolean; + className?: string; +} + +function SearchableSelect({ + value, + onValueChange, + options, + placeholder = "선택", + searchPlaceholder = "검색...", + emptyText = "항목을 찾을 수 없습니다.", + disabled = false, + className, +}: SearchableSelectProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find((opt) => opt.value === value); + + return ( + + + + + + + + + + {emptyText} + + + {options.map((option) => ( + { + onValueChange(option.value); + setOpen(false); + }} + className="text-xs" + > + +
+ {option.label} + {option.description && ( + + {option.description} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// ============================================================ +// 컴포넌트 +// ============================================================ + +export default function NodeSettingModal({ + isOpen, + onClose, + nodeType, + nodeId, + screenId, + screenName, + tableName, + tableLabel, + groupId, + groupScreens = [], + existingConfig, + onRefresh, +}: NodeSettingModalProps) { + // 탭 상태 + const [activeTab, setActiveTab] = useState("table-relation"); + + // 로딩 상태 + const [loading, setLoading] = useState(false); + + // 테이블 목록 (조인/필터 설정용) + const [tables, setTables] = useState([]); + const [tableColumns, setTableColumns] = useState>({}); + + // 테이블 연결 데이터 + const [tableRelations, setTableRelations] = useState([]); + + // 조인 설정 데이터 + const [fieldJoins, setFieldJoins] = useState([]); + + // 데이터 흐름 데이터 + const [dataFlows, setDataFlows] = useState([]); + + // ============================================================ + // 데이터 로드 + // ============================================================ + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setTables(response.data); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }, []); + + // 테이블 컬럼 로드 + const loadTableColumns = useCallback(async (tblName: string) => { + if (tableColumns[tblName]) return; // 이미 로드됨 + + try { + const response = await tableManagementApi.getColumnList(tblName); + if (response.success && response.data) { + setTableColumns(prev => ({ + ...prev, + [tblName]: response.data?.columns || [], + })); + } + } catch (error) { + console.error(`테이블 컬럼 로드 실패 (${tblName}):`, error); + } + }, [tableColumns]); + + // 테이블 연결 로드 + const loadTableRelations = useCallback(async () => { + if (!screenId) return; + + setLoading(true); + try { + const response = await getTableRelations({ screen_id: screenId }); + if (response.success && response.data) { + setTableRelations(response.data); + } + } catch (error) { + console.error("테이블 연결 로드 실패:", error); + } finally { + setLoading(false); + } + }, [screenId]); + + // 조인 설정 로드 + const loadFieldJoins = useCallback(async () => { + if (!screenId) return; + + setLoading(true); + try { + const response = await getFieldJoins(screenId); + if (response.success && response.data) { + setFieldJoins(response.data); + } + } catch (error) { + console.error("조인 설정 로드 실패:", error); + } finally { + setLoading(false); + } + }, [screenId]); + + // 데이터 흐름 로드 + const loadDataFlows = useCallback(async () => { + if (!groupId) return; + + setLoading(true); + try { + const response = await getDataFlows(groupId); + if (response.success && response.data) { + // 현재 화면 관련 흐름만 필터링 + const filtered = response.data.filter( + flow => flow.source_screen_id === screenId || flow.target_screen_id === screenId + ); + setDataFlows(filtered); + } + } catch (error) { + console.error("데이터 흐름 로드 실패:", error); + } finally { + setLoading(false); + } + }, [groupId, screenId]); + + // 모달 열릴 때 데이터 로드 + useEffect(() => { + if (isOpen) { + loadTables(); + loadTableRelations(); + loadFieldJoins(); + if (groupId) { + loadDataFlows(); + } + // 현재 테이블 컬럼 로드 + if (tableName) { + loadTableColumns(tableName); + } + } + }, [isOpen, loadTables, loadTableRelations, loadFieldJoins, loadDataFlows, tableName, groupId, loadTableColumns]); + + // ============================================================ + // 이벤트 핸들러 + // ============================================================ + + // 모달 닫기 + const handleClose = () => { + onClose(); + }; + + // 새로고침 + const handleRefresh = async () => { + setLoading(true); + try { + await Promise.all([ + loadTableRelations(), + loadFieldJoins(), + groupId ? loadDataFlows() : Promise.resolve(), + ]); + toast.success("데이터가 새로고침되었습니다."); + } catch (error) { + toast.error("새로고침 실패"); + } finally { + setLoading(false); + } + }; + + // ============================================================ + // 렌더링 + // ============================================================ + + // 모달 제목 + const modalTitle = nodeType === "screen" + ? `화면 설정: ${screenName}` + : `테이블 설정: ${tableLabel || tableName}`; + + // 모달 설명 + const modalDescription = nodeType === "screen" + ? "화면의 테이블 연결, 조인, 데이터 흐름을 설정합니다." + : "테이블의 조인 관계 및 필드 매핑을 설정합니다."; + + return ( + + + + + {nodeType === "screen" ? ( + + ) : ( + + )} + {modalTitle} + + + {modalDescription} + + + +
+ setActiveTab(v as TabId)} className="h-full flex flex-col"> +
+ + + + 테이블 연결 + 연결 + + + + 조인 설정 + 조인 + + + + 데이터 흐름 + 흐름 + + + + 필드 매핑 + 매핑 + + + + +
+ + {/* 탭 컨텐츠 */} +
+ {/* 탭1: 테이블 연결 */} + + + + + {/* 탭2: 조인 설정 */} + + + + + {/* 탭3: 데이터 흐름 */} + + + + + {/* 탭4: 필드 매핑 */} + + + +
+
+
+
+
+ ); +} + + +// ============================================================ +// 탭1: 테이블 연결 설정 +// ============================================================ + +interface TableRelationTabProps { + screenId: number; + screenName: string; + tableRelations: TableRelation[]; + tables: TableInfo[]; + loading: boolean; + onReload: () => void; + onRefreshVisualization?: () => void; + nodeType: "screen" | "table"; + existingConfig?: ExistingConfig; +} + +function TableRelationTab({ + screenId, + screenName, + tableRelations, + tables, + loading, + onReload, + onRefreshVisualization, + nodeType, + existingConfig, +}: TableRelationTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [editItem, setEditItem] = useState(null); + const [formData, setFormData] = useState({ + table_name: "", + relation_type: "main", + crud_operations: "CR", + description: "", + is_active: "Y", + }); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + table_name: "", + relation_type: "main", + crud_operations: "CR", + description: "", + is_active: "Y", + }); + setEditItem(null); + setIsEditing(false); + }; + + // 수정 모드 + const handleEdit = (item: TableRelation) => { + setEditItem(item); + setFormData({ + table_name: item.table_name, + relation_type: item.relation_type, + crud_operations: item.crud_operations, + description: item.description || "", + is_active: item.is_active, + }); + setIsEditing(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.table_name) { + toast.error("테이블을 선택해주세요."); + return; + } + + try { + const payload = { + screen_id: screenId, + ...formData, + }; + + let response; + if (editItem) { + response = await updateTableRelation(editItem.id, payload); + } else { + response = await createTableRelation(payload); + } + + if (response.success) { + toast.success(editItem ? "테이블 연결이 수정되었습니다." : "테이블 연결이 추가되었습니다."); + resetForm(); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("정말 삭제하시겠습니까?")) return; + + try { + const response = await deleteTableRelation(id); + if (response.success) { + toast.success("테이블 연결이 삭제되었습니다."); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "삭제 중 오류가 발생했습니다."); + } + }; + + // 화면 디자이너에서 추출한 테이블 관계를 통합 목록으로 변환 + const designerTableRelations = useMemo(() => { + if (nodeType !== "screen" || !existingConfig) return []; + + const result: Array<{ + id: string; + source: "designer"; + table_name: string; + table_label?: string; + relation_type: string; + crud_operations: string; + description: string; + filterColumns?: string[]; + joinColumnRefs?: Array<{ column: string; refTable: string; refTableLabel?: string; refColumn: string; }>; + }> = []; + + // 메인 테이블 추가 + if (existingConfig.mainTable) { + result.push({ + id: `designer-main-${existingConfig.mainTable}`, + source: "designer", + table_name: existingConfig.mainTable, + table_label: existingConfig.mainTable, + relation_type: "main", + crud_operations: "CRUD", + description: "화면의 주요 데이터 소스 테이블", + }); + } + + // 필터 테이블 추가 + if (existingConfig.filterTables) { + existingConfig.filterTables.forEach((ft, idx) => { + result.push({ + id: `designer-filter-${ft.tableName}-${idx}`, + source: "designer", + table_name: ft.tableName, + table_label: ft.tableLabel, + relation_type: "sub", + crud_operations: "R", + description: "마스터-디테일 필터 테이블", + filterColumns: ft.filterColumns, + joinColumnRefs: ft.joinColumnRefs, + }); + }); + } + + return result; + }, [nodeType, existingConfig]); + + // DB 테이블 관계와 디자이너 테이블 관계 통합 + const unifiedTableRelations = useMemo(() => { + // DB 관계 + const dbRelations = tableRelations.map(item => ({ + ...item, + id: item.id, + source: "db" as const, + })); + + // 디자이너 관계 (DB에 이미 있는 테이블은 제외) + const dbTableNames = new Set(tableRelations.map(r => r.table_name)); + const filteredDesignerRelations = designerTableRelations.filter( + dr => !dbTableNames.has(dr.table_name) + ); + + return [...filteredDesignerRelations, ...dbRelations]; + }, [tableRelations, designerTableRelations]); + + // 디자이너 항목 수정 (DB로 저장) + const handleEditDesignerRelation = (item: typeof designerTableRelations[0]) => { + setFormData({ + table_name: item.table_name, + relation_type: item.relation_type, + crud_operations: item.crud_operations, + description: item.description || "", + is_active: "Y", + }); + setEditItem(null); + setIsEditing(true); + }; + + return ( +
+ {/* 입력 폼 */} +
+
{isEditing ? "테이블 연결 수정" : "새 테이블 연결 추가"}
+ +
+
+ + setFormData(prev => ({ ...prev, table_name: v }))} + options={tables.map((t) => ({ + value: t.tableName, + label: t.displayName || t.tableName, + description: t.tableName !== t.displayName ? t.tableName : undefined, + }))} + placeholder="테이블 선택" + searchPlaceholder="테이블 검색..." + /> +
+ +
+ + setFormData(prev => ({ ...prev, relation_type: v }))} + options={[ + { value: "main", label: "메인 테이블" }, + { value: "sub", label: "서브 테이블" }, + { value: "lookup", label: "조회 테이블" }, + { value: "save", label: "저장 테이블" }, + ]} + placeholder="관계 유형" + searchPlaceholder="유형 검색..." + /> +
+ +
+ + setFormData(prev => ({ ...prev, crud_operations: v }))} + options={[ + { value: "C", label: "생성(C)" }, + { value: "R", label: "읽기(R)" }, + { value: "CR", label: "생성+읽기(CR)" }, + { value: "CRU", label: "생성+읽기+수정(CRU)" }, + { value: "CRUD", label: "전체(CRUD)" }, + ]} + placeholder="CRUD 권한" + searchPlaceholder="권한 검색..." + /> +
+ +
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="설명 입력" + className="h-9 text-xs" + /> +
+
+ +
+ {isEditing && ( + + )} + +
+
+ + {/* 목록 */} +
+ + + + 출처 + 테이블 + 관계 유형 + CRUD + 설명 + 작업 + + + + {loading ? ( + + + + + + ) : unifiedTableRelations.length === 0 ? ( + + + 등록된 테이블 연결이 없습니다. + + + ) : ( + unifiedTableRelations.map((item) => ( + + + + {item.source === "designer" ? "화면" : "DB"} + + + +
+ {item.table_label || item.table_name} + {item.table_label && item.table_label !== item.table_name && ( + ({item.table_name}) + )} +
+ {/* 필터 테이블의 경우 필터 컬럼/조인 정보 표시 */} + {item.source === "designer" && "filterColumns" in item && item.filterColumns && item.filterColumns.length > 0 && ( +
+ {item.filterColumns.map((col, idx) => ( + + {col} + + ))} +
+ )} + {item.source === "designer" && "joinColumnRefs" in item && item.joinColumnRefs && item.joinColumnRefs.length > 0 && ( +
+ {item.joinColumnRefs.map((join, idx) => ( + + {join.column}→{join.refTable} + + ))} +
+ )} +
+ + + {item.relation_type === "main" ? "메인" : + item.relation_type === "sub" ? "필터" : + item.relation_type === "save" ? "저장" : + item.relation_type === "lookup" ? "조회" : item.relation_type} + + + {item.crud_operations} + + {item.description || "-"} + + +
+ {item.source === "db" ? ( + <> + + + + ) : ( + + )} +
+
+
+ )) + )} +
+
+
+
+ ); +} + + +// ============================================================ +// 탭2: 조인 설정 +// ============================================================ + +interface JoinSettingTabProps { + screenId: number; + tableName?: string; + fieldJoins: FieldJoin[]; + tables: TableInfo[]; + tableColumns: Record; + loading: boolean; + onReload: () => void; + onLoadColumns: (tableName: string) => void; + onRefreshVisualization?: () => void; + // 기존 설정 정보 (화면 디자이너에서 추출) + existingConfig?: ExistingConfig; +} + +// 화면 디자이너 조인 설정을 통합 형식으로 변환하기 위한 인터페이스 +interface UnifiedJoinItem { + id: number | string; // DB는 숫자, 화면 디자이너는 문자열 + source: "db" | "designer"; // 출처 + save_table: string; + save_table_label?: string; + save_column: string; + join_table: string; + join_table_label?: string; + join_column: string; + display_column?: string; + join_type: string; +} + +function JoinSettingTab({ + screenId, + tableName, + fieldJoins, + tables, + tableColumns, + loading, + onReload, + onLoadColumns, + onRefreshVisualization, + existingConfig, +}: JoinSettingTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [editItem, setEditItem] = useState(null); + const [editingDesignerItem, setEditingDesignerItem] = useState(null); + const [formData, setFormData] = useState({ + field_name: "", + save_table: tableName || "", + save_column: "", + join_table: "", + join_column: "", + display_column: "", + join_type: "LEFT", + filter_condition: "", + is_active: "Y", + }); + + // 테이블 라벨 가져오기 (tableName -> displayName) - 먼저 선언해야 함 + const tableLabel = tables.find(t => t.tableName === tableName)?.displayName; + + // 화면 디자이너 조인 설정을 통합 형식으로 변환 + // 1. 현재 테이블의 조인 설정 + const directJoins: UnifiedJoinItem[] = (existingConfig?.joinColumnRefs || []).map((ref, idx) => ({ + id: `designer-direct-${idx}`, + source: "designer" as const, + save_table: tableName || "", + save_table_label: tableLabel || tableName, + save_column: ref.column, + join_table: ref.refTable, + join_table_label: ref.refTableLabel, + join_column: ref.refColumn, + display_column: "", + join_type: "LEFT", + })); + + // 2. 필터 테이블들의 조인 설정 (화면 노드에서 열었을 때) + const filterTableJoins: UnifiedJoinItem[] = (existingConfig?.filterTables || []).flatMap((ft, ftIdx) => + (ft.joinColumnRefs || []).map((ref, refIdx) => ({ + id: `designer-filter-${ftIdx}-${refIdx}`, + source: "designer" as const, + save_table: ft.tableName, + save_table_label: ft.tableLabel || ft.tableName, + save_column: ref.column, + join_table: ref.refTable, + join_table_label: ref.refTableLabel, + join_column: ref.refColumn, + display_column: "", + join_type: "LEFT", + })) + ); + + // 모든 디자이너 조인 설정 통합 + const designerJoins: UnifiedJoinItem[] = [...directJoins, ...filterTableJoins]; + + // DB 조인 설정을 통합 형식으로 변환 + const dbJoins: UnifiedJoinItem[] = fieldJoins.map((item) => ({ + id: item.id, + source: "db" as const, + save_table: item.save_table, + save_table_label: item.save_table_label, + save_column: item.save_column, + join_table: item.join_table, + join_table_label: item.join_table_label, + join_column: item.join_column, + display_column: item.display_column, + join_type: item.join_type, + })); + + // 통합된 조인 목록 (화면 디자이너 + DB) + const unifiedJoins = [...designerJoins, ...dbJoins]; + + // 저장 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (formData.save_table) { + onLoadColumns(formData.save_table); + } + }, [formData.save_table, onLoadColumns]); + + // 조인 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (formData.join_table) { + onLoadColumns(formData.join_table); + } + }, [formData.join_table, onLoadColumns]); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + field_name: "", + save_table: tableName || "", + save_column: "", + join_table: "", + join_column: "", + display_column: "", + join_type: "LEFT", + filter_condition: "", + is_active: "Y", + }); + setEditItem(null); + setEditingDesignerItem(null); + setIsEditing(false); + }; + + // 수정 모드 (DB 설정) + const handleEdit = (item: FieldJoin) => { + setEditItem(item); + setEditingDesignerItem(null); + setFormData({ + field_name: item.field_name || "", + save_table: item.save_table, + save_column: item.save_column, + join_table: item.join_table, + join_column: item.join_column, + display_column: item.display_column, + join_type: item.join_type, + filter_condition: item.filter_condition || "", + is_active: item.is_active, + }); + setIsEditing(true); + // 컬럼 로드 + onLoadColumns(item.save_table); + onLoadColumns(item.join_table); + }; + + // 통합 목록에서 수정 버튼 클릭 + const handleEditUnified = (item: UnifiedJoinItem) => { + if (item.source === "db") { + // DB 설정은 기존 로직 사용 + const originalItem = fieldJoins.find(j => j.id === item.id); + if (originalItem) handleEdit(originalItem); + } else { + // 화면 디자이너 설정은 폼에 채우고 새로 저장하도록 + setEditItem(null); + setEditingDesignerItem(item); + setFormData({ + field_name: "", + save_table: item.save_table, + save_column: item.save_column, + join_table: item.join_table, + join_column: item.join_column, + display_column: item.display_column || "", + join_type: item.join_type, + filter_condition: "", + is_active: "Y", + }); + setIsEditing(true); + // 컬럼 로드 + onLoadColumns(item.save_table); + onLoadColumns(item.join_table); + } + }; + + // 통합 목록에서 삭제 버튼 클릭 + const handleDeleteUnified = async (item: UnifiedJoinItem) => { + if (item.source === "db") { + // DB 설정만 삭제 가능 + await handleDelete(item.id as number); + } else { + // 화면 디자이너 설정은 삭제 불가 (화면 디자이너에서 수정해야 함) + toast.info("화면 디자이너 설정은 화면 디자이너에서 수정해주세요."); + } + }; + + // 저장 + const handleSave = async () => { + if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column) { + toast.error("필수 필드를 모두 입력해주세요."); + return; + } + + try { + const payload = { + screen_id: screenId, + ...formData, + }; + + let response; + if (editItem) { + response = await updateFieldJoin(editItem.id, payload); + } else { + response = await createFieldJoin(payload); + } + + if (response.success) { + toast.success(editItem ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다."); + resetForm(); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("정말 삭제하시겠습니까?")) return; + + try { + const response = await deleteFieldJoin(id); + if (response.success) { + toast.success("조인 설정이 삭제되었습니다."); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "삭제 중 오류가 발생했습니다."); + } + }; + + // 저장 테이블 컬럼 + const saveTableColumns = tableColumns[formData.save_table] || []; + + // 조인 테이블 컬럼 + const joinTableColumns = tableColumns[formData.join_table] || []; + + return ( +
+ {/* 필터링 컬럼 정보 */} + {existingConfig?.filterColumns && existingConfig.filterColumns.length > 0 && ( +
+
+ + 필터링 컬럼 (마스터-디테일 연동) +
+
+ {existingConfig.filterColumns.map((col, idx) => ( + + {col} + + ))} +
+

+ * 이 컬럼들을 기준으로 상위 화면에서 데이터가 필터링됩니다. +

+
+ )} + + {/* 참조 정보 (이 테이블을 참조하는 다른 테이블들) */} + {existingConfig?.referencedBy && existingConfig.referencedBy.length > 0 && ( +
+
+ + 이 테이블을 참조하는 관계 +
+ + + + 참조하는 테이블 + 참조 유형 + 연결 + + + + {existingConfig.referencedBy.map((ref, idx) => ( + + + {ref.fromTableLabel || ref.fromTable} + + + + {ref.relationType} + + + + {ref.fromColumn} → {ref.toColumn} + + + ))} + +
+
+ )} + + {/* 입력 폼 */} +
+
{isEditing ? "조인 설정 수정" : "새 조인 설정 추가"}
+ +
+ {/* 저장 테이블 */} +
+ + { + setFormData(prev => ({ ...prev, save_table: v, save_column: "" })); + }} + options={tables.map((t) => ({ + value: t.tableName, + label: t.displayName || t.tableName, + description: t.tableName !== t.displayName ? t.tableName : undefined, + }))} + placeholder="테이블 선택" + searchPlaceholder="테이블 검색..." + /> +
+ + {/* 저장 컬럼 */} +
+ + setFormData(prev => ({ ...prev, save_column: v }))} + disabled={!formData.save_table} + options={saveTableColumns.map((c) => ({ + value: c.columnName, + label: c.displayName || c.columnName, + description: c.columnName !== c.displayName ? c.columnName : undefined, + }))} + placeholder="컬럼 선택" + searchPlaceholder="컬럼 검색..." + /> +
+ + {/* 조인 타입 */} +
+ + setFormData(prev => ({ ...prev, join_type: v }))} + options={[ + { value: "LEFT", label: "LEFT JOIN" }, + { value: "INNER", label: "INNER JOIN" }, + { value: "RIGHT", label: "RIGHT JOIN" }, + ]} + placeholder="조인 타입" + searchPlaceholder="타입 검색..." + /> +
+
+ +
+ {/* 조인 테이블 */} +
+ + { + setFormData(prev => ({ ...prev, join_table: v, join_column: "", display_column: "" })); + }} + options={tables.map((t) => ({ + value: t.tableName, + label: t.displayName || t.tableName, + description: t.tableName !== t.displayName ? t.tableName : undefined, + }))} + placeholder="테이블 선택" + searchPlaceholder="테이블 검색..." + /> +
+ + {/* 조인 컬럼 */} +
+ + setFormData(prev => ({ ...prev, join_column: v }))} + disabled={!formData.join_table} + options={joinTableColumns.map((c) => ({ + value: c.columnName, + label: c.displayName || c.columnName, + description: c.columnName !== c.displayName ? c.columnName : undefined, + }))} + placeholder="컬럼 선택" + searchPlaceholder="컬럼 검색..." + /> +
+ + {/* 표시 컬럼 */} +
+ + setFormData(prev => ({ ...prev, display_column: v }))} + disabled={!formData.join_table} + options={joinTableColumns.map((c) => ({ + value: c.columnName, + label: c.displayName || c.columnName, + description: c.columnName !== c.displayName ? c.columnName : undefined, + }))} + placeholder="표시할 컬럼 선택" + searchPlaceholder="컬럼 검색..." + /> +
+
+ +
+ {isEditing && ( + + )} + +
+
+ + {/* 통합 조인 목록 */} +
+
+ + + 조인 설정 목록 + + + 총 {unifiedJoins.length}개 + +
+ + + + 출처 + 저장 테이블 + FK 컬럼 + 조인 테이블 + PK 컬럼 + 타입 + 작업 + + + + {loading ? ( + + + + + + ) : unifiedJoins.length === 0 ? ( + + + 등록된 조인 설정이 없습니다. + + + ) : ( + unifiedJoins.map((item) => ( + + + {item.source === "designer" ? ( + + 화면 + + ) : ( + + DB + + )} + + {item.save_table_label || item.save_table} + {item.save_column} + {item.join_table_label || item.join_table} + {item.join_column} + + + {item.join_type} + + + +
+ + {item.source === "db" && ( + + )} +
+
+
+ )) + )} +
+
+ {designerJoins.length > 0 && ( +
+ * 화면: 화면 디자이너 설정 (수정 시 DB에 저장) | + * DB: DB 저장 설정 (직접 수정/삭제 가능) +
+ )} +
+
+ ); +} + + +// ============================================================ +// 탭3: 데이터 흐름 +// ============================================================ + +interface DataFlowTabProps { + screenId: number; + groupId?: number; + groupScreens: Array<{ screen_id: number; screen_name: string }>; + dataFlows: DataFlow[]; + loading: boolean; + onReload: () => void; + onRefreshVisualization?: () => void; +} + +function DataFlowTab({ + screenId, + groupId, + groupScreens, + dataFlows, + loading, + onReload, + onRefreshVisualization, +}: DataFlowTabProps) { + const [isEditing, setIsEditing] = useState(false); + const [editItem, setEditItem] = useState(null); + const [formData, setFormData] = useState({ + source_screen_id: screenId, + source_action: "", + target_screen_id: 0, + target_action: "", + flow_type: "unidirectional", + flow_label: "", + is_active: "Y", + }); + + // 폼 초기화 + const resetForm = () => { + setFormData({ + source_screen_id: screenId, + source_action: "", + target_screen_id: 0, + target_action: "", + flow_type: "unidirectional", + flow_label: "", + is_active: "Y", + }); + setEditItem(null); + setIsEditing(false); + }; + + // 수정 모드 + const handleEdit = (item: DataFlow) => { + setEditItem(item); + setFormData({ + source_screen_id: item.source_screen_id, + source_action: item.source_action || "", + target_screen_id: item.target_screen_id, + target_action: item.target_action || "", + flow_type: item.flow_type, + flow_label: item.flow_label || "", + is_active: item.is_active, + }); + setIsEditing(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.source_screen_id || !formData.target_screen_id) { + toast.error("소스 화면과 타겟 화면을 선택해주세요."); + return; + } + + try { + const payload = { + group_id: groupId, + ...formData, + }; + + let response; + if (editItem) { + response = await updateDataFlow(editItem.id, payload); + } else { + response = await createDataFlow(payload); + } + + if (response.success) { + toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다."); + resetForm(); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "저장에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "저장 중 오류가 발생했습니다."); + } + }; + + // 삭제 + const handleDelete = async (id: number) => { + if (!confirm("정말 삭제하시겠습니까?")) return; + + try { + const response = await deleteDataFlow(id); + if (response.success) { + toast.success("데이터 흐름이 삭제되었습니다."); + onReload(); + onRefreshVisualization?.(); + } else { + toast.error(response.message || "삭제에 실패했습니다."); + } + } catch (error: any) { + toast.error(error.message || "삭제 중 오류가 발생했습니다."); + } + }; + + // 그룹 없음 안내 + if (!groupId) { + return ( +
+ +

그룹 정보가 없습니다

+

+ 데이터 흐름 설정은 화면 그룹 내에서만 사용할 수 있습니다. +

+
+ ); + } + + return ( +
+ {/* 입력 폼 */} +
+
{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}
+ +
+ {/* 소스 화면 */} +
+ + setFormData(prev => ({ ...prev, source_screen_id: parseInt(v) }))} + options={groupScreens.map((s) => ({ + value: s.screen_id.toString(), + label: s.screen_name, + }))} + placeholder="화면 선택" + searchPlaceholder="화면 검색..." + /> +
+ + {/* 소스 액션 */} +
+ + setFormData(prev => ({ ...prev, source_action: e.target.value }))} + placeholder="예: 행 선택" + className="h-9 text-xs" + /> +
+ + {/* 타겟 화면 */} +
+ + setFormData(prev => ({ ...prev, target_screen_id: parseInt(v) }))} + options={groupScreens + .filter(s => s.screen_id !== formData.source_screen_id) + .map((s) => ({ + value: s.screen_id.toString(), + label: s.screen_name, + }))} + placeholder="화면 선택" + searchPlaceholder="화면 검색..." + /> +
+ + {/* 흐름 타입 */} +
+ + setFormData(prev => ({ ...prev, flow_type: v }))} + options={[ + { value: "unidirectional", label: "단방향" }, + { value: "bidirectional", label: "양방향" }, + ]} + placeholder="흐름 타입" + searchPlaceholder="타입 검색..." + /> +
+
+ +
+ {isEditing && ( + + )} + +
+
+ + {/* 목록 */} +
+ + + + 소스 화면 + 액션 + 타겟 화면 + 흐름 + 작업 + + + + {loading ? ( + + + + + + ) : dataFlows.length === 0 ? ( + + + 등록된 데이터 흐름이 없습니다. + + + ) : ( + dataFlows.map((item) => ( + + + {item.source_screen_name || `화면 ${item.source_screen_id}`} + + + {item.source_action || "-"} + + + {item.target_screen_name || `화면 ${item.target_screen_id}`} + + + + {item.flow_type === "bidirectional" ? "양방향" : "단방향"} + + + +
+ + +
+
+
+ )) + )} +
+
+
+
+ ); +} + + +// ============================================================ +// 탭4: 필드-컬럼 매핑 (화면 컴포넌트와 DB 컬럼 연결) +// ============================================================ + +interface FieldMappingTabProps { + screenId: number; + tableName?: string; + tableColumns: ColumnTypeInfo[]; + loading: boolean; +} + +function FieldMappingTab({ + screenId, + tableName, + tableColumns, + loading, +}: FieldMappingTabProps) { + // 필드 매핑은 screen_layouts.properties에서 관리됨 + // 이 탭에서는 현재 매핑 상태를 조회하고 편집 가능하게 제공 + + return ( +
+
+
+ + 필드-컬럼 매핑 +
+

+ 화면 컴포넌트와 데이터베이스 컬럼 간의 바인딩을 설정합니다. +
+ 현재는 화면 디자이너에서 설정된 내용을 확인할 수 있습니다. +

+
+ + {/* 테이블 컬럼 목록 */} + {tableName && ( +
+
+ 테이블: {tableName} +
+ + + + 컬럼명 + 한글명 + 데이터 타입 + 웹 타입 + PK + + + + {loading ? ( + + + + + + ) : tableColumns.length === 0 ? ( + + + 컬럼 정보가 없습니다. + + + ) : ( + tableColumns.slice(0, 20).map((col) => ( + + {col.columnName} + {col.displayName} + {col.dbType} + + + {col.webType} + + + + {col.isPrimaryKey && ( + + PK + + )} + + + )) + )} + +
+ {tableColumns.length > 20 && ( +
+ + {tableColumns.length - 20}개 더 있음 +
+ )} +
+ )} + + {!tableName && ( +
+ +

테이블 정보가 없습니다

+

+ 테이블 노드에서 더블클릭하여 필드 매핑을 확인하세요. +

+
+ )} +
+ ); +} + diff --git a/frontend/components/screen/ScreenGroupModal.tsx b/frontend/components/screen/ScreenGroupModal.tsx new file mode 100644 index 00000000..351f3b18 --- /dev/null +++ b/frontend/components/screen/ScreenGroupModal.tsx @@ -0,0 +1,467 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Folder } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ScreenGroupModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; + group?: ScreenGroup | null; // 수정 모드일 때 기존 그룹 데이터 +} + +export function ScreenGroupModal({ + isOpen, + onClose, + onSuccess, + group, +}: ScreenGroupModalProps) { + const [currentCompanyCode, setCurrentCompanyCode] = useState(""); + const [isSuperAdmin, setIsSuperAdmin] = useState(false); + + const [formData, setFormData] = useState({ + group_name: "", + group_code: "", + description: "", + display_order: 0, + target_company_code: "", + parent_group_id: null as number | null, + }); + const [loading, setLoading] = useState(false); + const [companies, setCompanies] = useState<{ code: string; name: string }[]>([]); + const [availableParentGroups, setAvailableParentGroups] = useState([]); + const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false); + + // 그룹 경로 가져오기 (계층 구조 표시용) + const getGroupPath = (groupId: number): string => { + const grp = availableParentGroups.find((g) => g.id === groupId); + if (!grp) return ""; + + const path: string[] = [grp.group_name]; + let currentGroup = grp; + + while ((currentGroup as any).parent_group_id) { + const parent = availableParentGroups.find((g) => g.id === (currentGroup as any).parent_group_id); + if (parent) { + path.unshift(parent.group_name); + currentGroup = parent; + } else { + break; + } + } + + return path.join(" > "); + }; + + // 그룹을 계층 구조로 정렬 + const getSortedGroups = (): typeof availableParentGroups => { + const result: typeof availableParentGroups = []; + + const addChildren = (parentId: number | null, level: number) => { + const children = availableParentGroups + .filter((g) => (g as any).parent_group_id === parentId) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + + for (const child of children) { + result.push({ ...child, group_level: level } as any); + addChildren(child.id, level + 1); + } + }; + + addChildren(null, 1); + return result; + }; + + // 현재 사용자 정보 로드 + useEffect(() => { + const loadUserInfo = async () => { + try { + const response = await apiClient.get("/auth/me"); + const result = response.data; + if (result.success && result.data) { + const companyCode = result.data.companyCode || result.data.company_code || ""; + setCurrentCompanyCode(companyCode); + setIsSuperAdmin(companyCode === "*"); + } + } catch (error) { + console.error("사용자 정보 로드 실패:", error); + } + }; + + if (isOpen) { + loadUserInfo(); + } + }, [isOpen]); + + // 회사 목록 로드 (최고 관리자만) + useEffect(() => { + if (isSuperAdmin && isOpen) { + const loadCompanies = async () => { + try { + const response = await apiClient.get("/admin/companies"); + const result = response.data; + if (result.success && result.data) { + const companyList = result.data.map((c: any) => ({ + code: c.company_code, + name: c.company_name, + })); + setCompanies(companyList); + } + } catch (error) { + console.error("회사 목록 로드 실패:", error); + } + }; + loadCompanies(); + } + }, [isSuperAdmin, isOpen]); + + // 부모 그룹 목록 로드 (현재 회사의 대분류/중분류 그룹만) + useEffect(() => { + if (isOpen && currentCompanyCode) { + const loadParentGroups = async () => { + try { + const response = await apiClient.get(`/screen-groups/groups?size=1000`); + const result = response.data; + if (result.success && result.data) { + // 모든 그룹을 상위 그룹으로 선택 가능 (무한 중첩 지원) + setAvailableParentGroups(result.data); + } + } catch (error) { + console.error("부모 그룹 목록 로드 실패:", error); + } + }; + loadParentGroups(); + } + }, [isOpen, currentCompanyCode]); + + // 그룹 데이터가 변경되면 폼 초기화 + useEffect(() => { + if (currentCompanyCode) { + if (group) { + setFormData({ + group_name: group.group_name || "", + group_code: group.group_code || "", + description: group.description || "", + display_order: group.display_order || 0, + target_company_code: group.company_code || currentCompanyCode, + parent_group_id: (group as any).parent_group_id || null, + }); + } else { + setFormData({ + group_name: "", + group_code: "", + description: "", + display_order: 0, + target_company_code: currentCompanyCode, + parent_group_id: null, + }); + } + } + }, [group, isOpen, currentCompanyCode]); + + const handleSubmit = async () => { + // 필수 필드 검증 + if (!formData.group_name.trim()) { + toast.error("그룹명을 입력하세요"); + return; + } + if (!formData.group_code.trim()) { + toast.error("그룹 코드를 입력하세요"); + return; + } + + setLoading(true); + try { + let response; + if (group) { + // 수정 모드 + response = await updateScreenGroup(group.id, formData); + } else { + // 추가 모드 + response = await createScreenGroup({ + ...formData, + is_active: "Y", + }); + } + + if (response.success) { + toast.success(group ? "그룹이 수정되었습니다" : "그룹이 추가되었습니다"); + onSuccess(); + onClose(); + } else { + toast.error(response.message || "작업에 실패했습니다"); + } + } catch (error: any) { + console.error("그룹 저장 실패:", error); + toast.error("그룹 저장에 실패했습니다"); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {group ? "그룹 수정" : "그룹 추가"} + + + 화면 그룹 정보를 입력하세요 + + + +
+ {/* 회사 선택 (최고 관리자만) */} + {isSuperAdmin && ( +
+ + +

+ 선택한 회사에 그룹이 생성됩니다 +

+
+ )} + + {/* 부모 그룹 선택 (하위 그룹 만들기) - 트리 구조 + 검색 */} +
+ + + + + + + + + + + 그룹을 찾을 수 없습니다 + + + {/* 대분류로 생성 옵션 */} + { + setFormData({ ...formData, parent_group_id: null }); + setIsParentGroupSelectOpen(false); + }} + className="text-xs sm:text-sm" + > + + + 대분류로 생성 + + {/* 계층 구조로 그룹 표시 */} + {getSortedGroups().map((parentGroup) => ( + { + setFormData({ ...formData, parent_group_id: parentGroup.id }); + setIsParentGroupSelectOpen(false); + }} + className="text-xs sm:text-sm" + > + + {/* 들여쓰기로 계층 표시 */} + + + {parentGroup.group_name} + + + ))} + + + + + +

+ 부모 그룹을 선택하면 하위 그룹으로 생성됩니다 +

+
+ + {/* 그룹명 */} +
+ + + setFormData({ ...formData, group_name: e.target.value }) + } + placeholder="그룹명을 입력하세요" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 그룹 코드 */} +
+ + + setFormData({ ...formData, group_code: e.target.value }) + } + placeholder="영문 대문자와 언더스코어로 입력" + className="h-8 text-xs sm:h-10 sm:text-sm" + disabled={!!group} // 수정 모드일 때는 코드 변경 불가 + /> + {group && ( +

+ 그룹 코드는 수정할 수 없습니다 +

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