From efa95af4b9fcae8d8a98e7ddd10fcb2d99c5e681 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 15 Jan 2026 17:36:38 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EntityJoinController에서 중복 제거 설정 관련 주석 및 코드 삭제 - screenGroupController와 tableManagementController에서 AuthenticatedRequest 타입을 일반 Request로 변경 - 불필요한 로그 및 주석 제거로 코드 가독성 향상 - tableManagementController에서 에러 메시지 개선 --- .../src/controllers/entityJoinController.ts | 129 +- .../src/controllers/screenGroupController.ts | 136 +- .../controllers/tableManagementController.ts | 429 ++-- .../admin/screenMng/screenMngList/page.tsx | 2 - .../app/(main)/screens/[screenId]/page.tsx | 13 +- frontend/app/globals.css | 231 -- .../screen/InteractiveScreenViewer.tsx | 1012 +++++---- .../screen/InteractiveScreenViewerDynamic.tsx | 1 - .../components/screen/ScreenSettingModal.tsx | 71 +- .../components/screen/TableSettingModal.tsx | 240 ++- frontend/lib/api/entityJoin.ts | 7 - frontend/lib/api/tableManagement.ts | 6 +- .../button-primary/ButtonPrimaryComponent.tsx | 155 +- .../SplitPanelLayoutComponent.tsx | 1883 +++++------------ .../table-list/TableListComponent.tsx | 405 +--- 15 files changed, 1650 insertions(+), 3070 deletions(-) diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index ab9bbc46..4a541456 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -30,7 +30,6 @@ export class EntityJoinController { autoFilter, // 🔒 멀티테넌시 자동 필터 dataFilter, // 🆕 데이터 필터 (JSON 문자열) excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 - deduplication, // 🆕 중복 제거 설정 (JSON 문자열) userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -50,9 +49,6 @@ export class EntityJoinController { // search가 문자열인 경우 JSON 파싱 searchConditions = typeof search === "string" ? JSON.parse(search) : search; - - // 🔍 디버그: 파싱된 검색 조건 로깅 - logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2)); } catch (error) { logger.warn("검색 조건 파싱 오류:", error); searchConditions = {}; @@ -155,24 +151,6 @@ export class EntityJoinController { } } - // 🆕 중복 제거 설정 처리 - let parsedDeduplication: { - enabled: boolean; - groupByColumn: string; - keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; - sortColumn?: string; - } | undefined = undefined; - if (deduplication) { - try { - parsedDeduplication = - typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication; - logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication); - } catch (error) { - logger.warn("중복 제거 설정 파싱 오류:", error); - parsedDeduplication = undefined; - } - } - const result = await tableManagementService.getTableDataWithEntityJoins( tableName, { @@ -190,26 +168,13 @@ export class EntityJoinController { screenEntityConfigs: parsedScreenEntityConfigs, dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 - deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달 } ); - // 🆕 중복 제거 처리 (결과 데이터에 적용) - let finalData = result; - if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) { - logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`); - const originalCount = result.data.length; - finalData = { - ...result, - data: this.deduplicateData(result.data, parsedDeduplication), - }; - logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`); - } - res.status(200).json({ success: true, message: "Entity 조인 데이터 조회 성공", - data: finalData, + data: result, }); } catch (error) { logger.error("Entity 조인 데이터 조회 실패", error); @@ -584,98 +549,6 @@ export class EntityJoinController { }); } } - - /** - * 중복 데이터 제거 (메모리 내 처리) - */ - private deduplicateData( - data: any[], - config: { - groupByColumn: string; - keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; - sortColumn?: string; - } - ): any[] { - if (!data || data.length === 0) return data; - - // 그룹별로 데이터 분류 - const groups: Record = {}; - - for (const row of data) { - const groupKey = row[config.groupByColumn]; - if (groupKey === undefined || groupKey === null) continue; - - if (!groups[groupKey]) { - groups[groupKey] = []; - } - groups[groupKey].push(row); - } - - // 각 그룹에서 하나의 행만 선택 - const result: any[] = []; - - for (const [groupKey, rows] of Object.entries(groups)) { - if (rows.length === 0) continue; - - let selectedRow: any; - - switch (config.keepStrategy) { - case "latest": - // 정렬 컬럼 기준 최신 (가장 큰 값) - if (config.sortColumn) { - rows.sort((a, b) => { - const aVal = a[config.sortColumn!]; - const bVal = b[config.sortColumn!]; - if (aVal === bVal) return 0; - if (aVal > bVal) return -1; - return 1; - }); - } - selectedRow = rows[0]; - break; - - case "earliest": - // 정렬 컬럼 기준 최초 (가장 작은 값) - if (config.sortColumn) { - rows.sort((a, b) => { - const aVal = a[config.sortColumn!]; - const bVal = b[config.sortColumn!]; - if (aVal === bVal) return 0; - if (aVal < bVal) return -1; - return 1; - }); - } - selectedRow = rows[0]; - break; - - case "base_price": - // base_price가 true인 행 선택 - selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0]; - break; - - case "current_date": - // 오늘 날짜 기준 유효 기간 내 행 선택 - const today = new Date().toISOString().split("T")[0]; - selectedRow = rows.find((r) => { - const startDate = r.start_date; - const endDate = r.end_date; - if (!startDate) return true; - if (startDate <= today && (!endDate || endDate >= today)) return true; - return false; - }) || rows[0]; - break; - - default: - selectedRow = rows[0]; - } - - if (selectedRow) { - result.push(selectedRow); - } - } - - return result; - } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 2d7bc0e1..569fe793 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,23 +1,18 @@ import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { MultiLangService } from "../services/multilangService"; -import { AuthenticatedRequest } from "../types/auth"; // pool 인스턴스 가져오기 const pool = getPool(); -// 다국어 서비스 인스턴스 -const multiLangService = new MultiLangService(); - // ============================================================ // 화면 그룹 (screen_groups) CRUD // ============================================================ // 화면 그룹 목록 조회 -export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { +export const getScreenGroups = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + 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); @@ -89,10 +84,10 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) }; // 화면 그룹 상세 조회 -export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const getScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = ` SELECT sg.*, @@ -135,10 +130,10 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) = }; // 화면 그룹 생성 -export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const createScreenGroup = async (req: Request, res: Response) => { try { - const userCompanyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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) { @@ -196,47 +191,6 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response // 업데이트된 데이터 반환 const updatedResult = await pool.query(`SELECT * FROM screen_groups WHERE id = $1`, [newGroupId]); - // 다국어 카테고리 자동 생성 (그룹 경로 기반) - try { - // 그룹 경로 조회 (상위 그룹 → 현재 그룹) - const groupPathResult = await pool.query( - `WITH RECURSIVE group_path AS ( - SELECT id, parent_group_id, group_name, group_level, 1 as depth - FROM screen_groups - WHERE id = $1 - UNION ALL - SELECT g.id, g.parent_group_id, g.group_name, g.group_level, gp.depth + 1 - FROM screen_groups g - INNER JOIN group_path gp ON g.id = gp.parent_group_id - WHERE g.parent_group_id IS NOT NULL - ) - SELECT group_name FROM group_path - ORDER BY depth DESC`, - [newGroupId] - ); - - const groupPath = groupPathResult.rows.map((r: any) => r.group_name); - - // 회사 이름 조회 - let companyName = "공통"; - if (finalCompanyCode !== "*") { - const companyResult = await pool.query( - `SELECT company_name FROM company_mng WHERE company_code = $1`, - [finalCompanyCode] - ); - if (companyResult.rows.length > 0) { - companyName = companyResult.rows[0].company_name; - } - } - - // 다국어 카테고리 생성 - await multiLangService.ensureScreenGroupCategory(finalCompanyCode, companyName, groupPath); - logger.info("화면 그룹 다국어 카테고리 자동 생성 완료", { groupPath, companyCode: finalCompanyCode }); - } catch (multilangError: any) { - // 다국어 카테고리 생성 실패해도 그룹 생성은 성공으로 처리 - logger.warn("화면 그룹 다국어 카테고리 생성 실패 (무시하고 계속):", multilangError.message); - } - logger.info("화면 그룹 생성", { userCompanyCode, finalCompanyCode, groupId: newGroupId, groupName: group_name, parentGroupId: parent_group_id }); res.json({ success: true, data: updatedResult.rows[0], message: "화면 그룹이 생성되었습니다." }); @@ -250,10 +204,10 @@ export const createScreenGroup = async (req: AuthenticatedRequest, res: Response }; // 화면 그룹 수정 -export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const updateScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const userCompanyCode = req.user!.companyCode; + 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; // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 @@ -339,10 +293,10 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response }; // 화면 그룹 삭제 -export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const deleteScreenGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_groups WHERE id = $1`; const params: any[] = [id]; @@ -375,10 +329,10 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response // ============================================================ // 그룹에 화면 추가 -export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => { +export const addScreenToGroup = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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) { @@ -415,10 +369,10 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) }; // 그룹에서 화면 제거 -export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => { +export const removeScreenFromGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_group_screens WHERE id = $1`; const params: any[] = [id]; @@ -446,10 +400,10 @@ export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Resp }; // 그룹 내 화면 순서/역할 수정 -export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => { +export const updateScreenInGroup = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { screen_role, display_order, is_default } = req.body; let query = ` @@ -485,9 +439,9 @@ export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Respon // ============================================================ // 화면 필드 조인 목록 조회 -export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => { +export const getFieldJoins = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { screen_id } = req.query; let query = ` @@ -526,10 +480,10 @@ export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => }; // 화면 필드 조인 생성 -export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => { +export const createFieldJoin = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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, @@ -567,10 +521,10 @@ export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) }; // 화면 필드 조인 수정 -export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => { +export const updateFieldJoin = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -612,10 +566,10 @@ export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) }; // 화면 필드 조인 삭제 -export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => { +export const deleteFieldJoin = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_field_joins WHERE id = $1`; const params: any[] = [id]; @@ -646,9 +600,9 @@ export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) // ============================================================ // 데이터 흐름 목록 조회 -export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => { +export const getDataFlows = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { group_id, source_screen_id } = req.query; let query = ` @@ -696,10 +650,10 @@ export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => }; // 데이터 흐름 생성 -export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => { +export const createDataFlow = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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 @@ -735,10 +689,10 @@ export const createDataFlow = async (req: AuthenticatedRequest, res: Response) = }; // 데이터 흐름 수정 -export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => { +export const updateDataFlow = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + 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 @@ -778,10 +732,10 @@ export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) = }; // 데이터 흐름 삭제 -export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => { +export const deleteDataFlow = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_data_flows WHERE id = $1`; const params: any[] = [id]; @@ -812,9 +766,9 @@ export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) = // ============================================================ // 화면-테이블 관계 목록 조회 -export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => { +export const getTableRelations = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { screen_id, group_id } = req.query; let query = ` @@ -861,10 +815,10 @@ export const getTableRelations = async (req: AuthenticatedRequest, res: Response }; // 화면-테이블 관계 생성 -export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => { +export const createTableRelation = async (req: Request, res: Response) => { try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; + 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) { @@ -894,10 +848,10 @@ export const createTableRelation = async (req: AuthenticatedRequest, res: Respon }; // 화면-테이블 관계 수정 -export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => { +export const updateTableRelation = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; let query = ` @@ -929,10 +883,10 @@ export const updateTableRelation = async (req: AuthenticatedRequest, res: Respon }; // 화면-테이블 관계 삭제 -export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => { +export const deleteTableRelation = async (req: Request, res: Response) => { try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; let query = `DELETE FROM screen_table_relations WHERE id = $1`; const params: any[] = [id]; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index e8c5a1bb..401fe9ce 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -804,12 +804,6 @@ export async function getTableData( } } - // 🆕 최종 검색 조건 로그 - logger.info( - `🔍 최종 검색 조건 (enhancedSearch):`, - JSON.stringify(enhancedSearch) - ); - // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), @@ -893,10 +887,7 @@ 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}`); @@ -906,10 +897,7 @@ 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}`); @@ -917,25 +905,13 @@ export async function addTableData( } // 데이터 추가 - const result = await tableManagementService.addTableData(tableName, data); + await tableManagementService.addTableData(tableName, data); logger.info(`테이블 데이터 추가 완료: ${tableName}`); - // 무시된 컬럼이 있으면 경고 정보 포함 - const response: ApiResponse<{ - skippedColumns?: string[]; - savedColumns?: string[]; - }> = { + const response: ApiResponse = { success: true, - message: - result.skippedColumns.length > 0 - ? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})` - : "테이블 데이터를 성공적으로 추가했습니다.", - data: { - skippedColumns: - result.skippedColumns.length > 0 ? result.skippedColumns : undefined, - savedColumns: result.savedColumns, - }, + message: "테이블 데이터를 성공적으로 추가했습니다.", }; res.status(201).json(response); @@ -1663,10 +1639,10 @@ export async function toggleLogTable( /** * 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속) - * + * * @route GET /api/table-management/menu/:menuObjid/category-columns * @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회 - * + * * 예시: * - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정 * - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속) @@ -1679,10 +1655,7 @@ 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({ @@ -1708,11 +1681,8 @@ 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 ( @@ -1734,21 +1704,17 @@ 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 @@ -1778,31 +1744,20 @@ 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 @@ -1812,17 +1767,11 @@ 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({ @@ -1832,7 +1781,7 @@ export async function getCategoryColumnsByMenu( }); return; } - + const columnsQuery = ` SELECT ttc.table_name AS "tableName", @@ -1857,15 +1806,13 @@ 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({ @@ -1890,9 +1837,9 @@ export async function getCategoryColumnsByMenu( /** * 범용 다중 테이블 저장 API - * + * * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. - * + * * 요청 본문: * { * mainTable: { tableName: string, primaryKeyColumn: string }, @@ -1962,29 +1909,23 @@ 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}" @@ -1993,43 +1934,29 @@ 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 = ` @@ -2040,10 +1967,7 @@ 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); } @@ -2062,15 +1986,12 @@ 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; } @@ -2083,20 +2004,15 @@ 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 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 deleteParams = - options?.deleteOnlySubItems && options?.mainMarkerColumn - ? [savedPkValue, options.subMarkerValue ?? false] - : [savedPkValue]; - - logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { - deleteQuery, - deleteParams, - }); + logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); await client.query(deleteQuery, deleteParams); } @@ -2109,12 +2025,7 @@ 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, }; @@ -2128,8 +2039,7 @@ export async function multiTableSave( // 메인 마커 설정 if (options.mainMarkerColumn) { - mainSubItem[options.mainMarkerColumn] = - options.mainMarkerValue ?? true; + mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; } // company_code 추가 @@ -2152,30 +2062,20 @@ 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}" @@ -2194,26 +2094,14 @@ 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 = ` @@ -2223,11 +2111,7 @@ 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] }); } } @@ -2243,12 +2127,8 @@ 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 = ` @@ -2257,16 +2137,9 @@ 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} 저장 완료`); @@ -2307,11 +2180,8 @@ export async function multiTableSave( } /** - * 두 테이블 간의 엔티티 관계 자동 감지 - * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy - * - * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 - * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + * 두 테이블 간 엔티티 관계 조회 + * column_labels의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회 */ export async function getTableEntityRelations( req: AuthenticatedRequest, @@ -2320,54 +2190,93 @@ export async function getTableEntityRelations( try { const { leftTable, rightTable } = req.query; - logger.info( - `=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===` - ); - if (!leftTable || !rightTable) { - const response: ApiResponse = { + res.status(400).json({ success: false, message: "leftTable과 rightTable 파라미터가 필요합니다.", - error: { - code: "MISSING_PARAMETERS", - details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.", - }, - }; - res.status(400).json(response); + }); return; } - const tableManagementService = new TableManagementService(); - const relations = await tableManagementService.detectTableEntityRelations( - String(leftTable), - String(rightTable) - ); + logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable }); - logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`); + // 두 테이블의 컬럼 라벨 정보 조회 + const columnLabelsQuery = ` + SELECT + table_name, + column_name, + column_label, + web_type, + detail_settings + FROM column_labels + WHERE table_name IN ($1, $2) + AND web_type IN ('entity', 'category') + `; - const response: ApiResponse = { + const result = await query(columnLabelsQuery, [leftTable, rightTable]); + + // 관계 분석 + const relations: Array<{ + fromTable: string; + fromColumn: string; + toTable: string; + toColumn: string; + relationType: string; + }> = []; + + for (const row of result) { + try { + const detailSettings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings) + : row.detail_settings; + + if (detailSettings && detailSettings.referenceTable) { + const refTable = detailSettings.referenceTable; + const refColumn = detailSettings.referenceColumn || "id"; + + // leftTable과 rightTable 간의 관계인지 확인 + if ( + (row.table_name === leftTable && refTable === rightTable) || + (row.table_name === rightTable && refTable === leftTable) + ) { + relations.push({ + fromTable: row.table_name, + fromColumn: row.column_name, + toTable: refTable, + toColumn: refColumn, + relationType: row.web_type, + }); + } + } + } catch (parseError) { + logger.warn("detail_settings 파싱 오류:", { + table: row.table_name, + column: row.column_name, + error: parseError + }); + } + } + + logger.info("테이블 엔티티 관계 조회 완료", { + leftTable, + rightTable, + relationsCount: relations.length + }); + + res.json({ success: true, - message: `${relations.length}개의 엔티티 관계를 발견했습니다.`, data: { - leftTable: String(leftTable), - rightTable: String(rightTable), + leftTable, + rightTable, relations, }, - }; - - res.status(200).json(response); - } catch (error) { - logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error); - - const response: ApiResponse = { + }); + } catch (error: any) { + logger.error("테이블 엔티티 관계 조회 실패:", error); + res.status(500).json({ success: false, - message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.", - error: { - code: "ENTITY_RELATIONS_ERROR", - details: error instanceof Error ? error.message : "Unknown error", - }, - }; - - res.status(500).json(response); + message: "테이블 엔티티 관계 조회에 실패했습니다.", + error: error.message, + }); } } diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 4e2878eb..106870eb 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -241,5 +241,3 @@ export default function ScreenManagementPage() { ); } - - diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 4923ded7..b61d5dae 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -23,7 +23,6 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신 import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈 import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리 -import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 function ScreenViewPage() { const params = useParams(); @@ -114,7 +113,7 @@ function ScreenViewPage() { // 편집 모달 이벤트 리스너 등록 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); + console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); setEditModalConfig({ screenId: event.detail.screenId, @@ -346,10 +345,9 @@ function ScreenViewPage() { {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} {layoutReady && layout && layout.components.length > 0 ? ( - -
); })()} -
-
+ ) : ( // 빈 화면일 때
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 1614c9b8..a252eaff 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -388,237 +388,6 @@ select { border-spacing: 0 !important; } -/* ===== POP (Production Operation Panel) Styles ===== */ - -/* POP 전용 다크 테마 변수 */ -.pop-dark { - /* 배경 색상 */ - --pop-bg-deepest: 8 12 21; - --pop-bg-deep: 10 15 28; - --pop-bg-primary: 13 19 35; - --pop-bg-secondary: 18 26 47; - --pop-bg-tertiary: 25 35 60; - --pop-bg-elevated: 32 45 75; - - /* 네온 강조색 */ - --pop-neon-cyan: 0 212 255; - --pop-neon-cyan-bright: 0 240 255; - --pop-neon-cyan-dim: 0 150 190; - --pop-neon-pink: 255 0 102; - --pop-neon-purple: 138 43 226; - - /* 상태 색상 */ - --pop-success: 0 255 136; - --pop-success-dim: 0 180 100; - --pop-warning: 255 170 0; - --pop-warning-dim: 200 130 0; - --pop-danger: 255 51 51; - --pop-danger-dim: 200 40 40; - - /* 텍스트 색상 */ - --pop-text-primary: 255 255 255; - --pop-text-secondary: 180 195 220; - --pop-text-muted: 100 120 150; - - /* 테두리 색상 */ - --pop-border: 40 55 85; - --pop-border-light: 55 75 110; -} - -/* POP 전용 라이트 테마 변수 */ -.pop-light { - --pop-bg-deepest: 245 247 250; - --pop-bg-deep: 240 243 248; - --pop-bg-primary: 250 251 253; - --pop-bg-secondary: 255 255 255; - --pop-bg-tertiary: 245 247 250; - --pop-bg-elevated: 235 238 245; - - --pop-neon-cyan: 0 122 204; - --pop-neon-cyan-bright: 0 140 230; - --pop-neon-cyan-dim: 0 100 170; - --pop-neon-pink: 220 38 127; - --pop-neon-purple: 118 38 200; - - --pop-success: 22 163 74; - --pop-success-dim: 21 128 61; - --pop-warning: 245 158 11; - --pop-warning-dim: 217 119 6; - --pop-danger: 220 38 38; - --pop-danger-dim: 185 28 28; - - --pop-text-primary: 15 23 42; - --pop-text-secondary: 71 85 105; - --pop-text-muted: 148 163 184; - - --pop-border: 226 232 240; - --pop-border-light: 203 213 225; -} - -/* POP 배경 그리드 패턴 */ -.pop-bg-pattern::before { - content: ""; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - 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; - z-index: 0; -} - -.pop-light .pop-bg-pattern::before { - 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); -} - -.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); -} - -.pop-glow-success { - box-shadow: 0 0 15px rgba(0, 255, 136, 0.5); -} - -.pop-glow-warning { - box-shadow: 0 0 15px rgba(255, 170, 0, 0.5); -} - -.pop-glow-danger { - box-shadow: 0 0 15px rgba(255, 51, 51, 0.5); -} - -/* POP 펄스 글로우 애니메이션 */ -@keyframes pop-pulse-glow { - 0%, - 100% { - 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); - } -} - -.pop-animate-pulse-glow { - animation: pop-pulse-glow 2s ease-in-out infinite; -} - -/* POP 프로그레스 바 샤인 애니메이션 */ -@keyframes pop-progress-shine { - 0% { - opacity: 0; - transform: translateX(-20px); - } - 50% { - opacity: 1; - } - 100% { - opacity: 0; - transform: translateX(20px); - } -} - -.pop-progress-shine::after { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 20px; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3)); - animation: pop-progress-shine 1.5s ease-in-out infinite; -} - -/* POP 스크롤바 스타일 */ -.pop-scrollbar::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -.pop-scrollbar::-webkit-scrollbar-track { - background: rgb(var(--pop-bg-secondary)); -} - -.pop-scrollbar::-webkit-scrollbar-thumb { - background: rgb(var(--pop-border-light)); - border-radius: 9999px; -} - -.pop-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgb(var(--pop-neon-cyan-dim)); -} - -/* POP 스크롤바 숨기기 */ -.pop-hide-scrollbar::-webkit-scrollbar { - display: none; -} - -.pop-hide-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; -} - -/* ===== Marching Ants Animation (Excel Copy Border) ===== */ -@keyframes marching-ants-h { - 0% { - background-position: 0 0; - } - 100% { - background-position: 16px 0; - } -} - -@keyframes marching-ants-v { - 0% { - background-position: 0 0; - } - 100% { - background-position: 0 16px; - } -} - -.animate-marching-ants-h { - background: repeating-linear-gradient( - 90deg, - hsl(var(--primary)) 0, - hsl(var(--primary)) 4px, - transparent 4px, - transparent 8px - ); - background-size: 16px 2px; - animation: marching-ants-h 0.4s linear infinite; -} - -.animate-marching-ants-v { - background: repeating-linear-gradient( - 180deg, - hsl(var(--primary)) 0, - hsl(var(--primary)) 4px, - transparent 4px, - transparent 8px - ); - background-size: 2px 16px; - animation: marching-ants-v 0.4s linear infinite; -} - /* ===== 저장 테이블 막대기 애니메이션 ===== */ @keyframes saveBarDrop { 0% { diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8445f5e1..af6c9dbc 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback, useEffect, useMemo } from "react"; +import React, { useState, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; @@ -48,7 +48,6 @@ import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { buildGridClasses } from "@/lib/constants/columnSpans"; import { cn } from "@/lib/utils"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; -import { useMultiLang } from "@/hooks/useMultiLang"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar"; import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; @@ -89,7 +88,7 @@ const CascadingDropdownWrapper: React.FC = ({ relationCode, parentValue, }); - + // 실제 사용할 설정 (직접 설정 또는 API에서 가져온 설정) const effectiveConfig = config || relationConfig; @@ -110,7 +109,11 @@ const CascadingDropdownWrapper: React.FC = ({ const isDisabled = disabled || !parentValue || loading; return ( - onChange?.(newValue)} + disabled={isDisabled} + > {loading ? (
@@ -184,80 +187,22 @@ export const InteractiveScreenViewer: React.FC = ( const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기 - const { userLang } = useMultiLang(); // 다국어 훅 const [localFormData, setLocalFormData] = useState>({}); const [dateValues, setDateValues] = useState>({}); - - // 다국어 번역 상태 (langKeyId가 있는 컴포넌트들의 번역 텍스트) - const [translations, setTranslations] = useState>({}); - - // 다국어 키 수집 및 번역 로드 - useEffect(() => { - const loadTranslations = async () => { - // 모든 컴포넌트에서 langKey 수집 - const langKeysToFetch: string[] = []; - - const collectLangKeys = (comps: ComponentData[]) => { - comps.forEach((comp) => { - // 컴포넌트 라벨의 langKey - if ((comp as any).langKey) { - langKeysToFetch.push((comp as any).langKey); - } - // componentConfig 내의 langKey (버튼 텍스트 등) - if ((comp as any).componentConfig?.langKey) { - langKeysToFetch.push((comp as any).componentConfig.langKey); - } - // 자식 컴포넌트 재귀 처리 - if ((comp as any).children) { - collectLangKeys((comp as any).children); - } - }); - }; - - collectLangKeys(allComponents); - - // langKey가 있으면 배치 조회 - if (langKeysToFetch.length > 0 && userLang) { - try { - const { apiClient } = await import("@/lib/api/client"); - const response = await apiClient.post( - "/multilang/batch", - { - langKeys: [...new Set(langKeysToFetch)], // 중복 제거 - }, - { - params: { - userLang, - companyCode: user?.companyCode || "*", - }, - }, - ); - - if (response.data?.success && response.data?.data) { - setTranslations(response.data.data); - } - } catch (error) { - console.error("다국어 번역 로드 실패:", error); - } - } - }; - - loadTranslations(); - }, [allComponents, userLang, user?.companyCode]); - + // 팝업 화면 상태 const [popupScreen, setPopupScreen] = useState<{ screenId: number; title: string; size: string; } | null>(null); - + // 팝업 화면 레이아웃 상태 const [popupLayout, setPopupLayout] = useState([]); const [popupLoading, setPopupLoading] = useState(false); const [popupScreenResolution, setPopupScreenResolution] = useState<{ width: number; height: number } | null>(null); const [popupScreenInfo, setPopupScreenInfo] = useState<{ id: number; tableName?: string } | null>(null); - + // 팝업 전용 formData 상태 const [popupFormData, setPopupFormData] = useState>({}); @@ -265,68 +210,64 @@ export const InteractiveScreenViewer: React.FC = ( const finalFormData = { ...localFormData, ...externalFormData }; // 개선된 검증 시스템 (선택적 활성화) - const enhancedValidation = - enableEnhancedValidation && screenInfo && tableColumns.length > 0 - ? useFormValidation( - finalFormData, - allComponents.filter((c) => c.type === "widget") as WidgetComponent[], - tableColumns, - { - id: screenInfo.id, - screenName: screenInfo.tableName || "unknown", - tableName: screenInfo.tableName, - screenResolution: { width: 800, height: 600 }, - gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 }, - description: "동적 화면", - }, - { - enableRealTimeValidation: true, - validationDelay: 300, - enableAutoSave: false, - showToastMessages: true, - ...validationOptions, - }, - ) - : null; + const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0 + ? useFormValidation( + finalFormData, + allComponents.filter(c => c.type === 'widget') as WidgetComponent[], + tableColumns, + { + id: screenInfo.id, + screenName: screenInfo.tableName || "unknown", + tableName: screenInfo.tableName, + screenResolution: { width: 800, height: 600 }, + gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 }, + description: "동적 화면" + }, + { + enableRealTimeValidation: true, + validationDelay: 300, + enableAutoSave: false, + showToastMessages: true, + ...validationOptions, + } + ) + : null; // 자동값 생성 함수 - const generateAutoValue = useCallback( - async (autoValueType: string, ruleId?: string): Promise => { - const now = new Date(); - switch (autoValueType) { - case "current_datetime": - return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss - case "current_date": - return now.toISOString().slice(0, 10); // YYYY-MM-DD - case "current_time": - return now.toTimeString().slice(0, 8); // HH:mm:ss - case "current_user": - // 실제 접속중인 사용자명 사용 - return userName || "사용자"; // 사용자명이 없으면 기본값 - case "uuid": - return crypto.randomUUID(); - case "sequence": - return `SEQ_${Date.now()}`; - case "numbering_rule": - // 채번 규칙 사용 - if (ruleId) { - try { - const { generateNumberingCode } = await import("@/lib/api/numberingRule"); - const response = await generateNumberingCode(ruleId); - if (response.success && response.data) { - return response.data.generatedCode; - } - } catch (error) { - console.error("채번 규칙 코드 생성 실패:", error); + const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise => { + const now = new Date(); + switch (autoValueType) { + case "current_datetime": + return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss + case "current_date": + return now.toISOString().slice(0, 10); // YYYY-MM-DD + case "current_time": + return now.toTimeString().slice(0, 8); // HH:mm:ss + case "current_user": + // 실제 접속중인 사용자명 사용 + return userName || "사용자"; // 사용자명이 없으면 기본값 + case "uuid": + return crypto.randomUUID(); + case "sequence": + return `SEQ_${Date.now()}`; + case "numbering_rule": + // 채번 규칙 사용 + if (ruleId) { + try { + const { generateNumberingCode } = await import("@/lib/api/numberingRule"); + const response = await generateNumberingCode(ruleId); + if (response.success && response.data) { + return response.data.generatedCode; } + } catch (error) { + console.error("채번 규칙 코드 생성 실패:", error); } - return ""; - default: - return ""; - } - }, - [userName], - ); // userName 의존성 추가 + } + return ""; + default: + return ""; + } + }, [userName]); // userName 의존성 추가 // 팝업 화면 레이아웃 로드 React.useEffect(() => { @@ -335,29 +276,29 @@ export const InteractiveScreenViewer: React.FC = ( try { setPopupLoading(true); // console.log("🔍 팝업 화면 로드 시작:", popupScreen); - + // 화면 레이아웃과 화면 정보를 병렬로 가져오기 const [layout, screen] = await Promise.all([ screenApi.getLayout(popupScreen.screenId), - screenApi.getScreen(popupScreen.screenId), + screenApi.getScreen(popupScreen.screenId) ]); - + console.log("📊 팝업 화면 로드 완료:", { componentsCount: layout.components?.length || 0, screenInfo: { screenId: screen.screenId, - tableName: screen.tableName, + tableName: screen.tableName }, - popupFormData: {}, + popupFormData: {} }); - + setPopupLayout(layout.components || []); setPopupScreenResolution(layout.screenResolution || null); setPopupScreenInfo({ id: popupScreen.screenId, - tableName: screen.tableName, + tableName: screen.tableName }); - + // 팝업 formData 초기화 setPopupFormData({}); } catch (error) { @@ -368,7 +309,7 @@ export const InteractiveScreenViewer: React.FC = ( setPopupLoading(false); } }; - + loadPopupLayout(); } }, [popupScreen]); @@ -379,7 +320,7 @@ export const InteractiveScreenViewer: React.FC = ( external: externalFormData, local: localFormData, merged: formData, - hasExternalCallback: !!onFormDataChange, + hasExternalCallback: !!onFormDataChange }); // 폼 데이터 업데이트 @@ -388,16 +329,16 @@ export const InteractiveScreenViewer: React.FC = ( if (isPreviewMode) { return; } - + // console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`); - + // 항상 로컬 상태도 업데이트 setLocalFormData((prev) => ({ ...prev, [fieldName]: value, })); // console.log(`💾 로컬 상태 업데이트: ${fieldName} = "${value}"`); - + // 외부 콜백이 있는 경우에도 전달 (개별 필드 단위로) if (onFormDataChange) { onFormDataChange(fieldName, value); @@ -412,24 +353,29 @@ export const InteractiveScreenViewer: React.FC = ( // console.log("🔧 initAutoInputFields 실행 시작"); for (const comp of allComponents) { // 🆕 type: "component" 또는 type: "widget" 모두 처리 - if (comp.type === "widget" || comp.type === "component") { + if (comp.type === 'widget' || comp.type === 'component') { const widget = comp as WidgetComponent; const fieldName = widget.columnName || widget.id; - + // 🆕 autoFill 처리 (테이블 조회 기반 자동 입력) if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { const autoFillConfig = widget.autoFill || (comp as any).autoFill; const currentValue = formData[fieldName]; - if (currentValue === undefined || currentValue === "") { + if (currentValue === undefined || currentValue === '') { const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; - + // 사용자 정보에서 필터 값 가져오기 const userValue = user?.[userField]; - + if (userValue && sourceTable && filterColumn && displayColumn) { try { - const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn); - + const result = await tableTypeApi.getTableRecord( + sourceTable, + filterColumn, + userValue, + displayColumn + ); + updateFormData(fieldName, result.value); } catch (error) { console.error(`autoFill 조회 실패: ${fieldName}`, error); @@ -438,40 +384,37 @@ export const InteractiveScreenViewer: React.FC = ( } continue; // autoFill이 활성화되면 일반 자동입력은 건너뜀 } - + // 기존 widget 타입 전용 로직은 widget인 경우만 - if (comp.type !== "widget") continue; - + if (comp.type !== 'widget') continue; + // 텍스트 타입 위젯의 자동입력 처리 (기존 로직) - if ( - (widget.widgetType === "text" || widget.widgetType === "email" || widget.widgetType === "tel") && - widget.webTypeConfig - ) { + if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') && + widget.webTypeConfig) { const config = widget.webTypeConfig as TextTypeConfig; const isAutoInput = config?.autoInput || false; - + if (isAutoInput && config?.autoValueType) { // 이미 값이 있으면 덮어쓰지 않음 const currentValue = formData[fieldName]; console.log(`🔍 자동입력 필드 체크: ${fieldName}`, { currentValue, - isEmpty: currentValue === undefined || currentValue === "", + isEmpty: currentValue === undefined || currentValue === '', isAutoInput, - autoValueType: config.autoValueType, + autoValueType: config.autoValueType }); - - if (currentValue === undefined || currentValue === "") { - const autoValue = - config.autoValueType === "custom" - ? config.customValue || "" - : generateAutoValue(config.autoValueType); - + + if (currentValue === undefined || currentValue === '') { + const autoValue = config.autoValueType === "custom" + ? config.customValue || "" + : generateAutoValue(config.autoValueType); + console.log("🔄 자동입력 필드 초기화:", { fieldName, autoValueType: config.autoValueType, - autoValue, + autoValue }); - + updateFormData(fieldName, autoValue); } else { // console.log(`⏭️ 자동입력 건너뜀 (값 있음): ${fieldName} = "${currentValue}"`); @@ -528,7 +471,7 @@ export const InteractiveScreenViewer: React.FC = ( const FlowWidget = require("@/components/screen/widgets/FlowWidget").FlowWidget; // componentConfig에서 flowId 추출 const flowConfig = (comp as any).componentConfig || {}; - + console.log("🔍 InteractiveScreenViewer 플로우 위젯 변환:", { compType: comp.type, hasComponentConfig: !!(comp as any).componentConfig, @@ -536,7 +479,7 @@ export const InteractiveScreenViewer: React.FC = ( flowConfigFlowId: flowConfig.flowId, finalFlowId: flowConfig.flowId, }); - + const flowComponent = { ...comp, type: "flow" as const, @@ -546,9 +489,9 @@ export const InteractiveScreenViewer: React.FC = ( allowDataMove: flowConfig.allowDataMove || false, displayMode: flowConfig.displayMode || "horizontal", }; - + console.log("🔍 InteractiveScreenViewer 최종 flowComponent:", flowComponent); - + return (
@@ -560,7 +503,7 @@ export const InteractiveScreenViewer: React.FC = ( const componentType = (comp as any).componentType || (comp as any).componentId; if (comp.type === "tabs" || (comp.type === "component" && componentType === "tabs-widget")) { const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; - + // componentConfig에서 탭 정보 추출 const tabsConfig = comp.componentConfig || {}; const tabsComponent = { @@ -573,7 +516,7 @@ export const InteractiveScreenViewer: React.FC = ( allowCloseable: tabsConfig.allowCloseable || false, persistSelection: tabsConfig.persistSelection || false, }; - + console.log("🔍 탭 컴포넌트 렌더링:", { originalType: comp.type, componentType, @@ -581,11 +524,11 @@ export const InteractiveScreenViewer: React.FC = ( tabs: tabsComponent.tabs, tabsConfig, }); - + return (
-
@@ -598,7 +541,7 @@ export const InteractiveScreenViewer: React.FC = ( const componentConfig = (comp as any).componentConfig || {}; // config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접 const rackConfig = componentConfig.config || componentConfig; - + console.log("🏗️ 렉 구조 컴포넌트 렌더링:", { componentType, componentConfig, @@ -606,7 +549,7 @@ export const InteractiveScreenViewer: React.FC = ( fieldMapping: rackConfig.fieldMapping, formData, }); - + return (
= ( ); } - const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp; + const { widgetType, label, placeholder, required, readonly, columnName } = comp; const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; - // 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용) - const compLangKey = (comp as any).langKey; - const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel; - // 스타일 적용 const applyStyles = (element: React.ReactElement) => { if (!comp.style) return element; @@ -644,7 +583,7 @@ export const InteractiveScreenViewer: React.FC = ( return React.cloneElement(element, { style: { ...element.props.style, // 기존 스타일 유지 - ...styleWithoutSize, // width/height 제외한 스타일만 적용 + ...styleWithoutSize, // width/height 제외한 스타일만 적용 boxSizing: "border-box", }, }); @@ -659,13 +598,12 @@ export const InteractiveScreenViewer: React.FC = ( // 자동입력 관련 처리 const isAutoInput = config?.autoInput || false; - const autoValue = - isAutoInput && config?.autoValueType - ? config.autoValueType === "custom" - ? config.customValue || "" - : generateAutoValue(config.autoValueType) - : ""; - + const autoValue = isAutoInput && config?.autoValueType + ? config.autoValueType === "custom" + ? config.customValue || "" + : generateAutoValue(config.autoValueType) + : ""; + // 기본값 또는 자동값 설정 const displayValue = isAutoInput ? autoValue : currentValue || config?.defaultValue || ""; @@ -858,17 +796,17 @@ export const InteractiveScreenViewer: React.FC = ( }); const finalPlaceholder = config?.placeholder || placeholder || "선택하세요..."; - + // 🆕 연쇄 드롭다운 처리 (방법 1: 관계 코드 방식 - 권장) if (config?.cascadingRelationCode && config?.cascadingParentField) { const parentFieldValue = formData[config.cascadingParentField]; - + console.log("🔗 연쇄 드롭다운 (관계코드 방식):", { relationCode: config.cascadingRelationCode, parentField: config.cascadingParentField, parentValue: parentFieldValue, }); - + return applyStyles( = ( />, ); } - + // 🔄 연쇄 드롭다운 처리 (방법 2: 직접 설정 방식 - 레거시) if (config?.cascading?.enabled) { const cascadingConfig = config.cascading; const parentValue = formData[cascadingConfig.parentField]; - + return applyStyles( = ( />, ); } - + // 일반 Select const options = config?.options || [ { label: "옵션 1", value: "option1" }, @@ -1069,7 +1007,7 @@ export const InteractiveScreenViewer: React.FC = ( min={config?.minDate} max={config?.maxDate} className="w-full" - style={{ height: "100%" }} + style={{ height: "100%" }} />, ); } else { @@ -1137,20 +1075,19 @@ export const InteractiveScreenViewer: React.FC = ( case "file": { const widget = comp as WidgetComponent; const config = widget.webTypeConfig as FileTypeConfig | undefined; - + // 현재 파일 값 가져오기 const getCurrentValue = () => { const fieldName = widget.columnName || widget.id; return (externalFormData?.[fieldName] || localFormData[fieldName]) as any; }; - + const currentValue = getCurrentValue(); // 화면 ID 추출 (URL에서) - const screenId = - typeof window !== "undefined" && window.location.pathname.includes("/screens/") - ? parseInt(window.location.pathname.split("/screens/")[1]) - : null; + const screenId = typeof window !== 'undefined' && window.location.pathname.includes('/screens/') + ? parseInt(window.location.pathname.split('/screens/')[1]) + : null; console.log("📁 InteractiveScreenViewer - File 위젯:", { componentId: widget.id, @@ -1172,14 +1109,14 @@ export const InteractiveScreenViewer: React.FC = ( e.target.value = ""; // 파일 선택 취소 return; } - + const files = e.target.files; const fieldName = widget.columnName || widget.id; - + // 파일 선택을 취소한 경우 (files가 null이거나 길이가 0) if (!files || files.length === 0) { // console.log("📁 파일 선택 취소됨 - 기존 파일 유지"); - + // 현재 저장된 파일이 있는지 확인 const currentStoredValue = externalFormData?.[fieldName] || localFormData[fieldName]; if (currentStoredValue) { @@ -1208,19 +1145,19 @@ export const InteractiveScreenViewer: React.FC = ( // 실제 서버로 파일 업로드 try { toast.loading(`${files.length}개 파일 업로드 중...`); - + const uploadResult = await uploadFilesAndCreateData(files); - + if (uploadResult.success) { // console.log("📁 업로드 완료된 파일 데이터:", uploadResult.data); - - setLocalFormData((prev) => ({ ...prev, [fieldName]: uploadResult.data })); - + + setLocalFormData(prev => ({ ...prev, [fieldName]: uploadResult.data })); + // 외부 폼 데이터 변경 콜백 호출 if (onFormDataChange) { onFormDataChange(fieldName, uploadResult.data); } - + toast.success(uploadResult.message); } else { throw new Error("파일 업로드에 실패했습니다."); @@ -1228,7 +1165,7 @@ export const InteractiveScreenViewer: React.FC = ( } catch (error) { // console.error("파일 업로드 오류:", error); toast.error("파일 업로드에 실패했습니다."); - + // 파일 입력 초기화 e.target.value = ""; return; @@ -1237,13 +1174,13 @@ export const InteractiveScreenViewer: React.FC = ( const clearFile = () => { const fieldName = widget.columnName || widget.id; - setLocalFormData((prev) => ({ ...prev, [fieldName]: null })); - + setLocalFormData(prev => ({ ...prev, [fieldName]: null })); + // 외부 폼 데이터 변경 콜백 호출 if (onFormDataChange) { onFormDataChange(fieldName, null); } - + // 파일 input 초기화 const fileInput = document.querySelector(`input[type="file"][data-field="${fieldName}"]`) as HTMLInputElement; if (fileInput) { @@ -1257,31 +1194,39 @@ export const InteractiveScreenViewer: React.FC = ( // 새로운 JSON 구조에서 파일 정보 추출 const fileData = currentValue.files || []; if (fileData.length === 0) return null; - + return (
-
업로드된 파일 ({fileData.length}개)
+
+ 업로드된 파일 ({fileData.length}개) +
{fileData.map((fileInfo: any, index: number) => { - const isImage = fileInfo.type?.startsWith("image/"); - + const isImage = fileInfo.type?.startsWith('image/'); + return ( -
-
+
+
{isImage ? (
IMG
) : ( - + )}
-
-

{fileInfo.name}

-

{(fileInfo.size / 1024 / 1024).toFixed(2)} MB

-

{fileInfo.type || "알 수 없는 형식"}

-

- 업로드: {new Date(fileInfo.uploadedAt).toLocaleString("ko-KR")} +

+

{fileInfo.name}

+

+ {(fileInfo.size / 1024 / 1024).toFixed(2)} MB

+

{fileInfo.type || '알 수 없는 형식'}

+

업로드: {new Date(fileInfo.uploadedAt).toLocaleString('ko-KR')}

-
@@ -1292,7 +1237,7 @@ export const InteractiveScreenViewer: React.FC = ( }; const fieldName = widget.columnName || widget.id; - + return applyStyles(
{/* 파일 선택 영역 */} @@ -1305,45 +1250,45 @@ export const InteractiveScreenViewer: React.FC = ( required={required} multiple={config?.multiple} accept={config?.accept} - className="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" style={{ zIndex: 1 }} /> -
0 - ? "border-success/30 bg-success/10" - : "border-input bg-muted hover:border-input/80 hover:bg-muted/80", - readonly && "cursor-not-allowed opacity-50", - !readonly && "cursor-pointer", - )} - > +
0 + ? 'border-success/30 bg-success/10' + : 'border-input bg-muted hover:border-input/80 hover:bg-muted/80', + readonly && 'cursor-not-allowed opacity-50', + !readonly && 'cursor-pointer' + )}>
{currentValue && currentValue.files && currentValue.files.length > 0 ? ( <>
-
- +
+
-

- {currentValue.totalCount === 1 ? "파일 선택됨" : `${currentValue.totalCount}개 파일 선택됨`} +

+ {currentValue.totalCount === 1 + ? '파일 선택됨' + : `${currentValue.totalCount}개 파일 선택됨`}

-

+

총 {(currentValue.totalSize / 1024 / 1024).toFixed(2)}MB

-

클릭하여 다른 파일 선택

+

클릭하여 다른 파일 선택

) : ( <> - -

- {config?.dragDrop ? "파일을 드래그하여 놓거나 클릭하여 선택" : "클릭하여 파일 선택"} + +

+ {config?.dragDrop ? '파일을 드래그하여 놓거나 클릭하여 선택' : '클릭하여 파일 선택'}

{(config?.accept || config?.maxSize) && ( -
+
{config.accept &&
허용 형식: {config.accept}
} {config.maxSize &&
최대 크기: {config.maxSize}MB
} {config.multiple &&
다중 선택 가능
} @@ -1354,10 +1299,10 @@ export const InteractiveScreenViewer: React.FC = (
- + {/* 파일 미리보기 */} {renderFilePreview()} -
, +
); } @@ -1365,7 +1310,7 @@ export const InteractiveScreenViewer: React.FC = ( const widget = comp as WidgetComponent; const config = widget.webTypeConfig as CodeTypeConfig | undefined; - console.log("🔍 [InteractiveScreenViewer] Code 위젯 렌더링:", { + console.log(`🔍 [InteractiveScreenViewer] Code 위젯 렌더링:`, { componentId: widget.id, columnName: widget.columnName, codeCategory: config?.codeCategory, @@ -1399,11 +1344,11 @@ export const InteractiveScreenViewer: React.FC = ( onEvent={(event: string, data: any) => { // console.log(`Code widget event: ${event}`, data); }} - />, + /> ); } catch (error) { // console.error("DynamicWebTypeRenderer 로딩 실패, 기본 Select 사용:", error); - + // 폴백: 기본 Select 컴포넌트 사용 return applyStyles( , + ); } } case "entity": { - // DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용 const widget = comp as WidgetComponent; - return applyStyles( - updateFormData(fieldName, value), - onFormDataChange: updateFormData, - formData: formData, - readonly: readonly, - required: required, - placeholder: widget.placeholder || "엔티티를 선택하세요", - isInteractive: true, - className: "w-full h-full", - }} - />, + const config = widget.webTypeConfig as EntityTypeConfig | undefined; + + console.log("🏢 InteractiveScreenViewer - Entity 위젯:", { + componentId: widget.id, + widgetType: widget.widgetType, + config, + appliedSettings: { + entityName: config?.entityName, + displayField: config?.displayField, + valueField: config?.valueField, + multiple: config?.multiple, + defaultValue: config?.defaultValue, + }, + }); + + const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요..."; + const defaultOptions = [ + { label: "사용자", value: "user" }, + { label: "제품", value: "product" }, + { label: "주문", value: "order" }, + { label: "카테고리", value: "category" }, + ]; + + return ( + , ); } @@ -1455,9 +1433,9 @@ export const InteractiveScreenViewer: React.FC = ( if (isPreviewMode) { return; } - + const actionType = config?.actionType || "save"; - + try { switch (actionType) { case "save": @@ -1494,7 +1472,7 @@ export const InteractiveScreenViewer: React.FC = ( await handleCustomAction(); break; default: - // console.log(`알 수 없는 액션 타입: ${actionType}`); + // console.log(`알 수 없는 액션 타입: ${actionType}`); } } catch (error) { // console.error(`버튼 액션 실행 오류 (${actionType}):`, error); @@ -1525,24 +1503,24 @@ export const InteractiveScreenViewer: React.FC = ( // 기존 방식 (레거시 지원) const currentFormData = { ...localFormData, ...externalFormData }; // console.log("💾 기존 방식으로 저장 - currentFormData:", currentFormData); - + // formData 유효성 체크를 완화 (빈 객체라도 위젯이 있으면 저장 진행) - const hasWidgets = allComponents.some((comp) => comp.type === "widget"); + const hasWidgets = allComponents.some(comp => comp.type === 'widget'); if (!hasWidgets) { alert("저장할 입력 컴포넌트가 없습니다."); return; } // 필수 항목 검증 - const requiredFields = allComponents.filter((c) => c.required && (c.columnName || c.id)); - const missingFields = requiredFields.filter((field) => { + const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id)); + const missingFields = requiredFields.filter(field => { const fieldName = field.columnName || field.id; const value = currentFormData[fieldName]; return !value || value.toString().trim() === ""; }); if (missingFields.length > 0) { - const fieldNames = missingFields.map((f) => f.label || f.columnName || f.id).join(", "); + const fieldNames = missingFields.map(f => f.label || f.columnName || f.id).join(", "); alert(`다음 필수 항목을 입력해주세요: ${fieldNames}`); return; } @@ -1555,59 +1533,56 @@ export const InteractiveScreenViewer: React.FC = ( try { // 컬럼명 기반으로 데이터 매핑 const mappedData: Record = {}; - + // 입력 가능한 컴포넌트에서 데이터 수집 - allComponents.forEach((comp) => { + allComponents.forEach(comp => { // 위젯 컴포넌트이고 입력 가능한 타입인 경우 - if (comp.type === "widget") { + if (comp.type === 'widget') { const widget = comp as WidgetComponent; const fieldName = widget.columnName || widget.id; let value = currentFormData[fieldName]; - + console.log(`🔍 컴포넌트 처리: ${fieldName}`, { widgetType: widget.widgetType, formDataValue: value, hasWebTypeConfig: !!widget.webTypeConfig, - config: widget.webTypeConfig, + config: widget.webTypeConfig }); - + // 자동입력 필드인 경우에만 값이 없을 때 생성 - if ( - (widget.widgetType === "text" || widget.widgetType === "email" || widget.widgetType === "tel") && - widget.webTypeConfig - ) { + if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') && + widget.webTypeConfig) { const config = widget.webTypeConfig as TextTypeConfig; const isAutoInput = config?.autoInput || false; - + console.log(`📋 ${fieldName} 자동입력 체크:`, { isAutoInput, autoValueType: config?.autoValueType, hasValue: !!value, - value, + value }); - - if (isAutoInput && config?.autoValueType && (!value || value === "")) { + + if (isAutoInput && config?.autoValueType && (!value || value === '')) { // 자동입력이고 값이 없을 때만 생성 - value = - config.autoValueType === "custom" - ? config.customValue || "" - : generateAutoValue(config.autoValueType); - + value = config.autoValueType === "custom" + ? config.customValue || "" + : generateAutoValue(config.autoValueType); + console.log("💾 자동입력 값 저장 (값이 없어서 생성):", { fieldName, autoValueType: config.autoValueType, - generatedValue: value, + generatedValue: value }); } else if (isAutoInput && value) { console.log("💾 자동입력 필드지만 기존 값 유지:", { fieldName, - existingValue: value, + existingValue: value }); } else if (!isAutoInput) { // console.log(`📝 일반 입력 필드: ${fieldName} = "${value}"`); } } - + // 값이 있는 경우만 매핑 (빈 문자열도 포함하되, undefined는 제외) if (value !== undefined && value !== null && value !== "undefined") { // columnName이 있으면 columnName을 키로, 없으면 컴포넌트 ID를 키로 사용 @@ -1626,34 +1601,35 @@ export const InteractiveScreenViewer: React.FC = ( 매핑된데이터: mappedData, 화면정보: screenInfo, 전체컴포넌트수: allComponents.length, - 위젯컴포넌트수: allComponents.filter((c) => c.type === "widget").length, + 위젯컴포넌트수: allComponents.filter(c => c.type === 'widget').length, }); // 각 컴포넌트의 상세 정보 로그 // console.log("🔍 컴포넌트별 데이터 수집 상세:"); - allComponents.forEach((comp) => { - if (comp.type === "widget") { + allComponents.forEach(comp => { + if (comp.type === 'widget') { const widget = comp as WidgetComponent; const fieldName = widget.columnName || widget.id; const value = currentFormData[fieldName]; - const hasValue = value !== undefined && value !== null && value !== ""; + const hasValue = value !== undefined && value !== null && value !== ''; // console.log(` - ${fieldName} (${widget.widgetType}): "${value}" (값있음: ${hasValue}, 컬럼명: ${widget.columnName})`); } }); - + // 매핑된 데이터가 비어있으면 경고 if (Object.keys(mappedData).length === 0) { // console.warn("⚠️ 매핑된 데이터가 없습니다. 빈 데이터로 저장됩니다."); } // 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용) - const tableName = - screenInfo.tableName || allComponents.find((c) => c.columnName)?.tableName || "dynamic_form_data"; // 기본값 + const tableName = screenInfo.tableName || + allComponents.find(c => c.columnName)?.tableName || + "dynamic_form_data"; // 기본값 // 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음) const writerValue = user.userId; const companyCodeValue = user.companyCode || ""; - + console.log("👤 현재 사용자 정보:", { userId: user.userId, userName: userName, @@ -1685,11 +1661,11 @@ export const InteractiveScreenViewer: React.FC = ( if (result.success) { alert("저장되었습니다."); // console.log("✅ 저장 성공:", result.data); - + // 저장 후 데이터 초기화 (선택사항) if (onFormDataChange) { const resetData: Record = {}; - Object.keys(formData).forEach((key) => { + Object.keys(formData).forEach(key => { resetData[key] = ""; }); onFormDataChange(resetData); @@ -1703,25 +1679,27 @@ export const InteractiveScreenViewer: React.FC = ( } }; + // 삭제 액션 const handleDeleteAction = async () => { const confirmMessage = config?.confirmMessage || "정말로 삭제하시겠습니까?"; - + if (!confirm(confirmMessage)) { return; } // 삭제할 레코드 ID가 필요 (폼 데이터에서 id 필드 찾기) const recordId = formData["id"] || formData["ID"] || formData["objid"]; - + if (!recordId) { alert("삭제할 데이터를 찾을 수 없습니다. (ID가 없음)"); return; } // 테이블명 결정 - const tableName = - screenInfo?.tableName || allComponents.find((c) => c.columnName)?.tableName || "unknown_table"; + const tableName = screenInfo?.tableName || + allComponents.find(c => c.columnName)?.tableName || + "unknown_table"; if (!tableName || tableName === "unknown_table") { alert("테이블 정보가 없어 삭제할 수 없습니다."); @@ -1731,17 +1709,16 @@ export const InteractiveScreenViewer: React.FC = ( try { // console.log("🗑️ 삭제 실행:", { recordId, tableName, formData }); - // screenId 전달하여 제어관리 실행 가능하도록 함 - const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id); + const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName); if (result.success) { alert("삭제되었습니다."); // console.log("✅ 삭제 성공"); - + // 삭제 후 폼 초기화 if (onFormDataChange) { const resetData: Record = {}; - Object.keys(formData).forEach((key) => { + Object.keys(formData).forEach(key => { resetData[key] = ""; }); onFormDataChange(resetData); @@ -1758,13 +1735,13 @@ export const InteractiveScreenViewer: React.FC = ( // 편집 액션 const handleEditAction = () => { console.log("✏️ 수정 액션 실행"); - + // 버튼 컴포넌트의 수정 모달 설정 가져오기 const editModalTitle = config?.editModalTitle || ""; const editModalDescription = config?.editModalDescription || ""; - + console.log("📝 버튼 수정 모달 설정:", { editModalTitle, editModalDescription }); - + // EditModal 열기 이벤트 발생 const event = new CustomEvent("openEditModal", { detail: { @@ -1793,7 +1770,7 @@ export const InteractiveScreenViewer: React.FC = ( const handleSearchAction = () => { // console.log("🔍 검색 실행:", formData); // 검색 로직 - const searchTerms = Object.values(formData).filter((v) => v && v.toString().trim()); + const searchTerms = Object.values(formData).filter(v => v && v.toString().trim()); if (searchTerms.length === 0) { alert("검색할 내용을 입력해주세요."); } else { @@ -1806,7 +1783,7 @@ export const InteractiveScreenViewer: React.FC = ( if (confirm("모든 입력을 초기화하시겠습니까?")) { if (onFormDataChange) { const resetData: Record = {}; - Object.keys(formData).forEach((key) => { + Object.keys(formData).forEach(key => { resetData[key] = ""; }); onFormDataChange(resetData); @@ -1826,24 +1803,22 @@ export const InteractiveScreenViewer: React.FC = ( // 닫기 액션 const handleCloseAction = () => { // console.log("❌ 닫기 액션 실행"); - + // 모달 내부에서 실행되는지 확인 const isInModal = document.querySelector('[role="dialog"]') !== null; const isInPopup = window.opener !== null; - + if (isInModal) { // 모달 내부인 경우: 모달의 닫기 버튼 클릭하거나 모달 닫기 이벤트 발생 // console.log("🔄 모달 내부에서 닫기 - 모달 닫기 시도"); - + // 모달의 닫기 버튼을 찾아서 클릭 - const modalCloseButton = document.querySelector( - '[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close', - ); + const modalCloseButton = document.querySelector('[role="dialog"] button[aria-label*="Close"], [role="dialog"] button[data-dismiss="modal"], [role="dialog"] .dialog-close'); if (modalCloseButton) { (modalCloseButton as HTMLElement).click(); } else { // ESC 키 이벤트 발생시키기 - const escEvent = new KeyboardEvent("keydown", { key: "Escape", keyCode: 27, which: 27 }); + const escEvent = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27 }); document.dispatchEvent(escEvent); } } else if (isInPopup) { @@ -1860,7 +1835,7 @@ export const InteractiveScreenViewer: React.FC = ( // 팝업 액션 const handlePopupAction = () => { // console.log("🎯 팝업 액션 실행:", { popupScreenId: config?.popupScreenId }); - + if (config?.popupScreenId) { // 화면 모달 열기 setPopupScreen({ @@ -1879,17 +1854,17 @@ export const InteractiveScreenViewer: React.FC = ( // 네비게이션 액션 const handleNavigateAction = () => { const navigateType = config?.navigateType || "url"; - + if (navigateType === "screen" && config?.navigateScreenId) { // 화면으로 이동 const screenPath = `/screens/${config.navigateScreenId}`; - + console.log("🎯 화면으로 이동:", { screenId: config.navigateScreenId, target: config.navigateTarget || "_self", - path: screenPath, + path: screenPath }); - + if (config.navigateTarget === "_blank") { window.open(screenPath, "_blank"); } else { @@ -1899,9 +1874,9 @@ export const InteractiveScreenViewer: React.FC = ( // URL로 이동 console.log("🔗 URL로 이동:", { url: config.navigateUrl, - target: config.navigateTarget || "_self", + target: config.navigateTarget || "_self" }); - + if (config.navigateTarget === "_blank") { window.open(config.navigateUrl, "_blank"); } else { @@ -1911,7 +1886,7 @@ export const InteractiveScreenViewer: React.FC = ( console.log("🔗 네비게이션 정보가 설정되지 않았습니다:", { navigateType, hasUrl: !!config?.navigateUrl, - hasScreenId: !!config?.navigateScreenId, + hasScreenId: !!config?.navigateScreenId }); } }; @@ -1934,24 +1909,17 @@ export const InteractiveScreenViewer: React.FC = ( } }; - // 버튼 텍스트 다국어 적용 (componentConfig.langKey 확인) - const buttonLangKey = (widget as any).componentConfig?.langKey; - const buttonText = - buttonLangKey && translations[buttonLangKey] - ? translations[buttonLangKey] - : (widget as any).componentConfig?.text || label || "버튼"; - // 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용 const hasCustomColors = config?.backgroundColor || config?.textColor; - + return applyStyles( , + {label || "버튼"} + ); } @@ -1984,73 +1952,71 @@ export const InteractiveScreenViewer: React.FC = ( // 파일 첨부 컴포넌트 처리 if (isFileComponent(component)) { const fileComponent = component as FileComponent; - + console.log("🎯 File 컴포넌트 렌더링:", { componentId: fileComponent.id, currentUploadedFiles: fileComponent.uploadedFiles?.length || 0, hasOnFormDataChange: !!onFormDataChange, - userInfo: user ? { userId: user.userId, companyCode: user.companyCode } : "no user", + userInfo: user ? { userId: user.userId, companyCode: user.companyCode } : "no user" }); + + const handleFileUpdate = useCallback(async (updates: Partial) => { + // 실제 화면에서는 파일 업데이트를 처리 + console.log("📎 InteractiveScreenViewer - 파일 컴포넌트 업데이트:", { + updates, + hasUploadedFiles: !!updates.uploadedFiles, + uploadedFilesCount: updates.uploadedFiles?.length || 0, + hasOnFormDataChange: !!onFormDataChange + }); + + if (updates.uploadedFiles && onFormDataChange) { + const fieldName = fileComponent.columnName || fileComponent.id; + + // attach_file_info 테이블 구조에 맞는 데이터 생성 + const fileInfoForDB = updates.uploadedFiles.map(file => ({ + objid: file.objid.replace('temp_', ''), // temp_ 제거 + target_objid: "", + saved_file_name: file.savedFileName, + real_file_name: file.realFileName, + doc_type: file.docType, + doc_type_name: file.docTypeName, + file_size: file.fileSize, + file_ext: file.fileExt, + file_path: file.filePath, + writer: file.writer, + regdate: file.regdate, + status: file.status, + parent_target_objid: "", + company_code: file.companyCode + })); - const handleFileUpdate = useCallback( - async (updates: Partial) => { - // 실제 화면에서는 파일 업데이트를 처리 - console.log("📎 InteractiveScreenViewer - 파일 컴포넌트 업데이트:", { - updates, + // console.log("💾 attach_file_info 형태로 변환된 데이터:", fileInfoForDB); + + // FormData에는 파일 연결 정보만 저장 (간단한 형태) + const formDataValue = { + fileCount: updates.uploadedFiles.length, + docType: fileComponent.fileConfig.docType, + files: updates.uploadedFiles.map(file => ({ + objid: file.objid, + realFileName: file.realFileName, + fileSize: file.fileSize, + status: file.status + })) + }; + + // console.log("📝 FormData 저장값:", { fieldName, formDataValue }); + onFormDataChange(fieldName, formDataValue); + + // TODO: 실제 API 연동 시 attach_file_info 테이블에 저장 + // await saveFilesToDatabase(fileInfoForDB); + + } else { + console.warn("⚠️ 파일 업데이트 실패:", { hasUploadedFiles: !!updates.uploadedFiles, - uploadedFilesCount: updates.uploadedFiles?.length || 0, - hasOnFormDataChange: !!onFormDataChange, + hasOnFormDataChange: !!onFormDataChange }); - - if (updates.uploadedFiles && onFormDataChange) { - const fieldName = fileComponent.columnName || fileComponent.id; - - // attach_file_info 테이블 구조에 맞는 데이터 생성 - const fileInfoForDB = updates.uploadedFiles.map((file) => ({ - objid: file.objid.replace("temp_", ""), // temp_ 제거 - target_objid: "", - saved_file_name: file.savedFileName, - real_file_name: file.realFileName, - doc_type: file.docType, - doc_type_name: file.docTypeName, - file_size: file.fileSize, - file_ext: file.fileExt, - file_path: file.filePath, - writer: file.writer, - regdate: file.regdate, - status: file.status, - parent_target_objid: "", - company_code: file.companyCode, - })); - - // console.log("💾 attach_file_info 형태로 변환된 데이터:", fileInfoForDB); - - // FormData에는 파일 연결 정보만 저장 (간단한 형태) - const formDataValue = { - fileCount: updates.uploadedFiles.length, - docType: fileComponent.fileConfig.docType, - files: updates.uploadedFiles.map((file) => ({ - objid: file.objid, - realFileName: file.realFileName, - fileSize: file.fileSize, - status: file.status, - })), - }; - - // console.log("📝 FormData 저장값:", { fieldName, formDataValue }); - onFormDataChange(fieldName, formDataValue); - - // TODO: 실제 API 연동 시 attach_file_info 테이블에 저장 - // await saveFilesToDatabase(fileInfoForDB); - } else { - console.warn("⚠️ 파일 업데이트 실패:", { - hasUploadedFiles: !!updates.uploadedFiles, - hasOnFormDataChange: !!onFormDataChange, - }); - } - }, - [fileComponent, onFormDataChange], - ); + } + }, [fileComponent, onFormDataChange]); return (
@@ -2105,10 +2071,7 @@ export const InteractiveScreenViewer: React.FC = ( (component.label || component.style?.labelText) && !templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함 - // 다국어 라벨 텍스트 결정 (langKey가 있으면 번역 텍스트 사용) - const langKey = (component as any).langKey; - const originalLabelText = component.style?.labelText || component.label || ""; - const labelText = langKey && translations[langKey] ? translations[langKey] : originalLabelText; + const labelText = component.style?.labelText || component.label || ""; // 라벨 표시 여부 로그 (디버깅용) if (component.type === "widget") { @@ -2117,8 +2080,6 @@ export const InteractiveScreenViewer: React.FC = ( hideLabel, shouldShowLabel, labelText, - langKey, - hasTranslation: !!translations[langKey], }); } @@ -2133,6 +2094,7 @@ export const InteractiveScreenViewer: React.FC = ( marginBottom: component.style?.labelMarginBottom || "4px", }; + // 상위에서 라벨을 표시한 경우, 컴포넌트 내부에서는 라벨을 숨김 const componentForRendering = shouldShowLabel ? { @@ -2148,122 +2110,112 @@ export const InteractiveScreenViewer: React.FC = ( -
- {/* 테이블 옵션 툴바 */} - +
+ {/* 테이블 옵션 툴바 */} + + + {/* 메인 컨텐츠 */} +
+ {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} + {shouldShowLabel && ( + + )} - {/* 메인 컨텐츠 */} -
- {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} - {shouldShowLabel && ( - - )} - - {/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */} -
- {renderInteractiveWidget(componentForRendering)} -
-
+ {/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */} +
{renderInteractiveWidget(componentForRendering)}
+
- {/* 개선된 검증 패널 (선택적 표시) */} - {showValidationPanel && enhancedValidation && ( -
- { - const success = await enhancedValidation.saveForm(); - if (success) { - toast.success("데이터가 성공적으로 저장되었습니다!"); - } - }} - canSave={enhancedValidation.canSave} - compact={true} - showDetails={false} - /> -
- )} + {/* 개선된 검증 패널 (선택적 표시) */} + {showValidationPanel && enhancedValidation && ( +
+ { + const success = await enhancedValidation.saveForm(); + if (success) { + toast.success("데이터가 성공적으로 저장되었습니다!"); + } + }} + canSave={enhancedValidation.canSave} + compact={true} + showDetails={false} + /> +
+ )} - {/* 모달 화면 */} - { - setPopupScreen(null); - setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 - }} - > - - - {popupScreen?.title || "상세 정보"} - - -
- {popupLoading ? ( -
-
화면을 불러오는 중...
-
- ) : popupLayout.length > 0 ? ( -
- {/* 팝업에서도 실제 위치와 크기로 렌더링 */} - {popupLayout.map((popupComponent) => ( -
{ + setPopupScreen(null); + setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 + }}> + + + {popupScreen?.title || "상세 정보"} + + +
+ {popupLoading ? ( +
+
화면을 불러오는 중...
+
+ ) : popupLayout.length > 0 ? ( +
+ {/* 팝업에서도 실제 위치와 크기로 렌더링 */} + {popupLayout.map((popupComponent) => ( +
+ {/* 🎯 핵심 수정: 팝업 전용 formData 사용 */} + { + console.log("💾 팝업 formData 업데이트:", { + fieldName, + value, + valueType: typeof value, + prevFormData: popupFormData + }); + + setPopupFormData(prev => ({ + ...prev, + [fieldName]: value + })); }} - > - {/* 🎯 핵심 수정: 팝업 전용 formData 사용 */} - { - console.log("💾 팝업 formData 업데이트:", { - fieldName, - value, - valueType: typeof value, - prevFormData: popupFormData, - }); - - setPopupFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); - }} - /> -
- ))} -
- ) : ( -
-
화면 데이터가 없습니다.
-
- )} -
-
-
+ /> +
+ ))} +
+ ) : ( +
+
화면 데이터가 없습니다.
+
+ )} +
+ + diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index fbb2d4ec..af4a4542 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -365,7 +365,6 @@ export const InteractiveScreenViewerDynamic: React.FC 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달) onFormDataChange={handleFormDataChange} screenId={screenInfo?.id} tableName={screenInfo?.tableName} diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index c7373e5c..36e2bbe5 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -416,6 +416,10 @@ export function ScreenSettingModal({ 개요 + + + 테이블 설정 + 제어 관리 @@ -466,7 +470,22 @@ export function ScreenSettingModal({ /> - {/* 탭 2: 제어 관리 */} + {/* 탭 2: 테이블 설정 */} + + {mainTable && ( + {}} // 탭에서는 닫기 불필요 + tableName={mainTable} + tableLabel={mainTableLabel} + screenId={currentScreenId} + onSaveSuccess={handleRefresh} + isEmbedded={true} // 임베드 모드 + /> + )} + + + {/* 탭 3: 제어 관리 */} 메인 테이블 - {mainTable && ( - - )}
{mainTable ? ( @@ -3742,10 +3758,11 @@ function ControlManagementTab({
{/* 버튼 프리뷰 */}
{currentLabel || "버튼"} @@ -3870,6 +3887,34 @@ function ControlManagementTab({
+ {/* 버튼 모서리 (borderRadius) */} +
+ +
+ + 버튼 모서리 둥글기 +
+
+ {/* 확인 메시지 설정 (save/delete 액션에서만 표시) */} {((editedValues[btn.id]?.actionType || btn.actionType) === "save" || (editedValues[btn.id]?.actionType || btn.actionType) === "delete") && ( diff --git a/frontend/components/screen/TableSettingModal.tsx b/frontend/components/screen/TableSettingModal.tsx index f0613b66..c176c25c 100644 --- a/frontend/components/screen/TableSettingModal.tsx +++ b/frontend/components/screen/TableSettingModal.tsx @@ -129,6 +129,7 @@ interface TableSettingModalProps { columns?: ColumnInfo[]; filterColumns?: string[]; onSaveSuccess?: () => void; + isEmbedded?: boolean; // 탭 안에 임베드 모드로 표시 } // 검색 가능한 Select 컴포넌트 @@ -256,6 +257,7 @@ export function TableSettingModal({ columns = [], filterColumns = [], onSaveSuccess, + isEmbedded = false, }: TableSettingModalProps) { const [activeTab, setActiveTab] = useState("columns"); const [loading, setLoading] = useState(false); @@ -304,9 +306,19 @@ export function TableSettingModal({ // 초기 편집 상태 설정 const initialEdits: Record> = {}; columnsData.forEach((col) => { + // referenceTable이 설정되어 있으면 inputType은 entity여야 함 + let effectiveInputType = col.inputType || "direct"; + if (col.referenceTable && effectiveInputType !== "entity") { + effectiveInputType = "entity"; + } + // codeCategory/codeValue가 설정되어 있으면 inputType은 code여야 함 + if (col.codeCategory && effectiveInputType !== "code") { + effectiveInputType = "code"; + } + initialEdits[col.columnName] = { displayName: col.displayName, - inputType: col.inputType || "direct", + inputType: effectiveInputType, referenceTable: col.referenceTable, referenceColumn: col.referenceColumn, displayColumn: col.displayColumn, @@ -343,10 +355,10 @@ export function TableSettingModal({ try { // 모든 화면 조회 const screensResponse = await screenApi.getScreens({ size: 1000 }); - if (screensResponse.items) { + if (screensResponse.data) { const usingScreens: ScreenUsingTable[] = []; - screensResponse.items.forEach((screen: any) => { + screensResponse.data.forEach((screen: any) => { // 메인 테이블로 사용하는 경우 if (screen.tableName === tableName) { usingScreens.push({ @@ -418,6 +430,35 @@ export function TableSettingModal({ }, })); + // 입력 타입 변경 시 관련 필드 초기화 + if (field === "inputType") { + // 엔티티가 아닌 다른 타입으로 변경하면 참조 설정 초기화 + if (value !== "entity") { + setEditedColumns((prev) => ({ + ...prev, + [columnName]: { + ...prev[columnName], + inputType: value, + referenceTable: "", + referenceColumn: "", + displayColumn: "", + }, + })); + } + // 코드가 아닌 다른 타입으로 변경하면 코드 설정 초기화 + if (value !== "code") { + setEditedColumns((prev) => ({ + ...prev, + [columnName]: { + ...prev[columnName], + inputType: value, + codeCategory: "", + codeValue: "", + }, + })); + } + } + // 참조 테이블 변경 시 참조 컬럼 초기화 if (field === "referenceTable") { setEditedColumns((prev) => ({ @@ -452,8 +493,18 @@ export function TableSettingModal({ // detailSettings 처리 (Entity 타입인 경우) let finalDetailSettings = mergedColumn.detailSettings || ""; + + // referenceTable이 설정되어 있으면 inputType을 entity로 자동 설정 + let currentInputType = (mergedColumn.inputType || "") as string; + if (mergedColumn.referenceTable && currentInputType !== "entity") { + currentInputType = "entity"; + } + // codeCategory가 설정되어 있으면 inputType을 code로 자동 설정 + if (mergedColumn.codeCategory && currentInputType !== "code") { + currentInputType = "code"; + } - if (mergedColumn.inputType === "entity" && mergedColumn.referenceTable) { + if (currentInputType === "entity" && mergedColumn.referenceTable) { // 기존 detailSettings를 파싱하거나 새로 생성 let existingSettings: Record = {}; if (typeof mergedColumn.detailSettings === "string" && mergedColumn.detailSettings.trim().startsWith("{")) { @@ -479,7 +530,7 @@ export function TableSettingModal({ } // Code 타입인 경우 hierarchyRole을 detailSettings에 포함 - if (mergedColumn.inputType === "code" && (mergedColumn as any).hierarchyRole) { + if (currentInputType === "code" && (mergedColumn as any).hierarchyRole) { let existingSettings: Record = {}; if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { try { @@ -502,7 +553,7 @@ export function TableSettingModal({ const columnSetting: ColumnSettings = { columnName: columnName, columnLabel: mergedColumn.displayName || originalColumn.displayName || "", - webType: mergedColumn.inputType || originalColumn.inputType || "text", + inputType: currentInputType || "text", // referenceTable/codeCategory가 설정된 경우 자동 보정된 값 사용 detailSettings: finalDetailSettings, codeCategory: mergedColumn.codeCategory || originalColumn.codeCategory || "", codeValue: mergedColumn.codeValue || originalColumn.codeValue || "", @@ -593,6 +644,158 @@ export function TableSettingModal({ ]; }; + // 임베드 모드 + if (isEmbedded) { + return ( + <> +
+ {/* 헤더 */} +
+
+ + {tableLabel || tableName} + {tableName !== tableLabel && tableName !== (tableLabel || tableName) && ( + ({tableName}) + )} +
+
+ + + +
+
+
+ {/* 좌측: 탭 (40%) */} +
+ + + + + 컬럼 설정 + + + + 화면 연동 + + + + 참조 관계 + + + + + ({ + ...col, + isPK: col.columnName === "id" || col.columnName.endsWith("_id"), + isFK: (col.inputType as string) === "entity", + }))} + editedColumns={editedColumns} + selectedColumn={selectedColumn} + onSelectColumn={setSelectedColumn} + loading={loading} + /> + + + + + + + + + + +
+ + {/* 우측: 상세 설정 (60%) */} +
+ {selectedColumn && mergedColumns.find((c) => c.columnName === selectedColumn) ? ( + c.columnName === selectedColumn)!} + editedColumn={editedColumns[selectedColumn] || {}} + tableOptions={tableOptions} + inputTypeOptions={inputTypeOptions} + getRefColumnOptions={getRefColumnOptions} + loadingRefColumns={loadingRefColumns} + onColumnChange={(field, value) => handleColumnChange(selectedColumn, field, value)} + /> + ) : ( +
+
+ +

왼쪽에서 컬럼을 선택하면

+

상세 설정을 할 수 있습니다.

+
+
+ )} +
+
+
+ + {/* 테이블 타입 관리 모달 */} + + +
+

테이블 타입 관리

+ +
+
+ +
+
+
+ + ); + } + + // 기존 모달 모드 return ( <> @@ -843,6 +1046,7 @@ function ColumnListTab({
{filteredColumns.map((col) => { const edited = editedColumns[col.columnName] || {}; + // editedColumns에서 inputType을 가져옴 (초기화 시 이미 보정됨) const inputType = (edited.inputType || col.inputType || "text") as string; const isSelected = selectedColumn === col.columnName; @@ -873,23 +1077,17 @@ function ColumnListTab({ PK )} - {col.isFK && ( - - - FK - - )} - {(edited.referenceTable || col.referenceTable) && ( + {/* 엔티티 타입이거나 referenceTable이 설정되어 있으면 조인 배지 표시 (FK와 동일 의미) */} + {(inputType === "entity" || edited.referenceTable || col.referenceTable) && ( + 조인 )}
-
+
{col.columnName} - - {col.dataType}
); @@ -925,10 +1123,11 @@ function ColumnDetailPanel({ onColumnChange, }: ColumnDetailPanelProps) { const currentLabel = editedColumn.displayName ?? columnInfo.displayName ?? ""; - const currentInputType = (editedColumn.inputType ?? columnInfo.inputType ?? "text") as string; const currentRefTable = editedColumn.referenceTable ?? columnInfo.referenceTable ?? ""; const currentRefColumn = editedColumn.referenceColumn ?? columnInfo.referenceColumn ?? ""; const currentDisplayColumn = editedColumn.displayColumn ?? columnInfo.displayColumn ?? ""; + // editedColumn에서 inputType을 가져옴 (초기화 시 이미 보정됨) + const currentInputType = (editedColumn.inputType ?? columnInfo.inputType ?? "text") as string; return (
@@ -948,9 +1147,10 @@ function ColumnDetailPanel({ Primary Key )} - {columnInfo.isFK && ( - - Foreign Key + {/* 엔티티 타입이거나 referenceTable이 있으면 조인 배지 표시 */} + {(currentInputType === "entity" || currentRefTable) && ( + + 조인 )}
diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index ddb00ac7..cbcab931 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -77,12 +77,6 @@ export const entityJoinApi = { filterColumn?: string; filterValue?: any; }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) - deduplication?: { - enabled: boolean; - groupByColumn: string; - keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; - sortColumn?: string; - }; // 🆕 중복 제거 설정 companyCodeOverride?: string; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능) } = {}, ): Promise => { @@ -116,7 +110,6 @@ export const entityJoinApi = { autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 (오버라이드 포함) dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 - deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정 }, }); return response.data.data; diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 5953fd82..24ef25a0 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -13,7 +13,7 @@ export interface ColumnTypeInfo { dataType: string; dbType: string; webType: string; - inputType?: "direct" | "auto"; + inputType?: string; // text, number, entity, code, select, date, checkbox 등 detailSettings: string; description?: string; isNullable: string; @@ -39,11 +39,11 @@ export interface TableInfo { columnCount: number; } -// 컬럼 설정 타입 +// 컬럼 설정 타입 (백엔드 API와 동일한 필드명 사용) export interface ColumnSettings { columnName?: string; columnLabel: string; - webType: string; + inputType: string; // 백엔드에서 inputType으로 받음 detailSettings: string; codeCategory: string; codeValue: string; diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index e9bd91cb..fc6c2263 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -27,7 +27,6 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { applyMappingRules } from "@/lib/utils/dataMapping"; import { apiClient } from "@/lib/api/client"; -import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; export interface ButtonPrimaryComponentProps extends ComponentRendererProps { config?: ButtonPrimaryConfig; @@ -108,7 +107,6 @@ export const ButtonPrimaryComponent: React.FC = ({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const screenContext = useScreenContextOptional(); // 화면 컨텍스트 const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 - const { getTranslatedText } = useScreenMultiLang(); // 다국어 컨텍스트 // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) const splitPanelPosition = screenContext?.splitPanelPosition; @@ -301,20 +299,6 @@ export const ButtonPrimaryComponent: React.FC = ({ // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨) const [modalStoreData, setModalStoreData] = useState>({}); - // 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장) - const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState | null>(null); - - // splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화 - useEffect(() => { - const newData = splitPanelContext?.selectedLeftData ?? null; - setTrackedSelectedLeftData(newData); - // console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", { - // label: component.label, - // hasData: !!newData, - // dataKeys: newData ? Object.keys(newData) : [], - // }); - }, [splitPanelContext?.selectedLeftData, component.label]); - // modalDataStore 상태 구독 (실시간 업데이트) useEffect(() => { const actionConfig = component.componentConfig?.action; @@ -373,8 +357,8 @@ export const ButtonPrimaryComponent: React.FC = ({ // 2. 분할 패널 좌측 선택 데이터 확인 if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") { - // SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장) - if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) { + // SplitPanelContext에서 확인 + if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) { if (!hasSelection) { hasSelection = true; selectionCount = 1; @@ -413,7 +397,7 @@ export const ButtonPrimaryComponent: React.FC = ({ selectionCount, selectionSource, hasSplitPanelContext: !!splitPanelContext, - trackedSelectedLeftData: trackedSelectedLeftData, + selectedLeftData: splitPanelContext?.selectedLeftData, selectedRowsData: selectedRowsData?.length, selectedRows: selectedRows?.length, flowSelectedData: flowSelectedData?.length, @@ -445,7 +429,7 @@ export const ButtonPrimaryComponent: React.FC = ({ component.label, selectedRows, selectedRowsData, - trackedSelectedLeftData, + splitPanelContext?.selectedLeftData, flowSelectedData, splitPanelContext, modalStoreData, @@ -737,99 +721,61 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } + if (!screenContext) { + toast.error("화면 컨텍스트를 찾을 수 없습니다."); + return; + } + try { - let sourceData: any[] = []; + // 1. 소스 컴포넌트에서 데이터 가져오기 + let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); - // 1. ScreenContext에서 DataProvider를 통해 데이터 가져오기 시도 - if (screenContext) { - let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 + // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) + if (!sourceProvider) { + console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); + console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`); + + const allProviders = screenContext.getAllDataProviders(); + + // 테이블 리스트 우선 탐색 + for (const [id, provider] of allProviders) { + if (provider.componentType === "table-list") { + sourceProvider = provider; + console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); + break; + } + } + + // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 + if (!sourceProvider && allProviders.size > 0) { + const firstEntry = allProviders.entries().next().value; + if (firstEntry) { + sourceProvider = firstEntry[1]; + console.log( + `✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`, + ); + } + } - // 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 if (!sourceProvider) { - console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); - console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`); - - const allProviders = screenContext.getAllDataProviders(); - console.log(`📋 [ButtonPrimary] 등록된 DataProvider 목록:`, Array.from(allProviders.keys())); - - // 테이블 리스트 우선 탐색 - for (const [id, provider] of allProviders) { - if (provider.componentType === "table-list") { - sourceProvider = provider; - console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); - break; - } - } - - // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 - if (!sourceProvider && allProviders.size > 0) { - const firstEntry = allProviders.entries().next().value; - if (firstEntry) { - sourceProvider = firstEntry[1]; - console.log( - `✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`, - ); - } - } - } - - if (sourceProvider) { - const rawSourceData = sourceProvider.getSelectedData(); - sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : []; - console.log("📦 [ButtonPrimary] ScreenContext에서 소스 데이터 획득:", { - rawSourceData, - sourceData, - count: sourceData.length - }); - } - } else { - console.log("⚠️ [ButtonPrimary] ScreenContext가 없습니다. modalDataStore에서 데이터를 찾습니다."); - } - - // 2. ScreenContext에서 데이터를 찾지 못한 경우, modalDataStore에서 fallback 조회 - if (sourceData.length === 0) { - console.log("🔍 [ButtonPrimary] modalDataStore에서 데이터 탐색 시도..."); - - try { - const { useModalDataStore } = await import("@/stores/modalDataStore"); - const dataRegistry = useModalDataStore.getState().dataRegistry; - - console.log("📋 [ButtonPrimary] modalDataStore 전체 키:", Object.keys(dataRegistry)); - - // sourceTableName이 지정되어 있으면 해당 테이블에서 조회 - const sourceTableName = dataTransferConfig.sourceTableName || tableName; - - if (sourceTableName && dataRegistry[sourceTableName]) { - const modalData = dataRegistry[sourceTableName]; - sourceData = modalData.map((item: any) => item.originalData || item); - console.log(`✅ [ButtonPrimary] modalDataStore에서 데이터 발견 (${sourceTableName}):`, sourceData.length, "건"); - } else { - // 테이블명으로 못 찾으면 첫 번째 데이터 사용 - const firstKey = Object.keys(dataRegistry)[0]; - if (firstKey && dataRegistry[firstKey]?.length > 0) { - const modalData = dataRegistry[firstKey]; - sourceData = modalData.map((item: any) => item.originalData || item); - console.log(`✅ [ButtonPrimary] modalDataStore 첫 번째 키에서 데이터 발견 (${firstKey}):`, sourceData.length, "건"); - } - } - } catch (err) { - console.warn("⚠️ [ButtonPrimary] modalDataStore 접근 실패:", err); + toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다."); + return; } } - // 3. 여전히 데이터가 없으면 에러 + const rawSourceData = sourceProvider.getSelectedData(); + + // 🆕 배열이 아닌 경우 배열로 변환 + const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : []; + + console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) }); + if (!sourceData || sourceData.length === 0) { - console.error("❌ [ButtonPrimary] 선택된 데이터를 찾을 수 없습니다.", { - hasScreenContext: !!screenContext, - sourceComponentId: dataTransferConfig.sourceComponentId, - sourceTableName: dataTransferConfig.sourceTableName || tableName, - }); - toast.warning("선택된 데이터가 없습니다. 항목을 먼저 선택해주세요."); + toast.warning("선택된 데이터가 없습니다."); return; } - console.log("📦 [ButtonPrimary] 최종 소스 데이터:", { sourceData, count: sourceData.length }); - // 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값) let additionalData: Record = {}; @@ -1360,10 +1306,7 @@ export const ButtonPrimaryComponent: React.FC = ({ ...userStyle, }; - // 다국어 적용: componentConfig.langKey가 있으면 번역 텍스트 사용 - const langKey = (component as any).componentConfig?.langKey; - const originalButtonText = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; - const buttonContent = getTranslatedText(langKey, originalButtonText); + const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; return ( <> diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index a84c90fd..cbc7ea4f 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -32,13 +32,11 @@ import { DialogFooter, DialogDescription, } from "@/components/ui/dialog"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useSplitPanel } from "./SplitPanelContext"; -import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props @@ -59,8 +57,6 @@ export const SplitPanelLayoutComponent: React.FC const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능) const companyCode = (props as any).companyCode as string | undefined; - // 🌐 다국어 컨텍스트 - const { getTranslatedText } = useScreenMultiLang(); // 기본 설정값 const splitRatio = componentConfig.splitRatio || 30; @@ -141,13 +137,6 @@ export const SplitPanelLayoutComponent: React.FC if (item[underscoreKey] !== undefined) { return item[underscoreKey]; } - - // 6️⃣ 🆕 모든 키에서 _fieldName으로 끝나는 키 찾기 - // 예: partner_id_customer_name (프론트엔드가 customer_id로 추론했지만 실제는 partner_id인 경우) - const matchingKey = Object.keys(item).find((key) => key.endsWith(`_${fieldName}`)); - if (matchingKey && item[matchingKey] !== undefined) { - return item[matchingKey]; - } } return undefined; @@ -175,11 +164,6 @@ export const SplitPanelLayoutComponent: React.FC const [rightSearchQuery, setRightSearchQuery] = useState(""); const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); - - // 🆕 추가 탭 관련 상태 - const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭 - const [tabsData, setTabsData] = useState>({}); // 탭별 데이터 캐시 - const [tabsLoading, setTabsLoading] = useState>({}); // 탭별 로딩 상태 const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 @@ -190,10 +174,6 @@ export const SplitPanelLayoutComponent: React.FC const [rightCategoryMappings, setRightCategoryMappings] = useState< Record> >({}); // 우측 카테고리 매핑 - - // 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨) - const [categoryCodeLabels, setCategoryCodeLabels] = useState>({}); - const { toast } = useToast(); // 추가 모달 상태 @@ -227,12 +207,12 @@ export const SplitPanelLayoutComponent: React.FC const splitPanelId = `split-panel-${component.id}`; // 디버깅: Context 연결 상태 확인 - // console.log("🔗 [SplitPanelLayout] Context 연결 상태:", { - // componentId: component.id, - // splitPanelId, - // hasRegisterFunc: typeof ctxRegisterSplitPanel === "function", - // splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음", - // }); + console.log("🔗 [SplitPanelLayout] Context 연결 상태:", { + componentId: component.id, + splitPanelId, + hasRegisterFunc: typeof ctxRegisterSplitPanel === "function", + splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음", + }); // Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행 const ctxRegisterRef = useRef(ctxRegisterSplitPanel); @@ -257,15 +237,15 @@ export const SplitPanelLayoutComponent: React.FC isDragging: false, }; - // console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", { - // splitPanelId, - // panelInfo, - // }); + console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", { + splitPanelId, + panelInfo, + }); ctxRegisterRef.current(splitPanelId, panelInfo); return () => { - // console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId); + console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId); ctxUnregisterRef.current(splitPanelId); }; // 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리 @@ -333,11 +313,11 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 그룹별 합산된 데이터 계산 const summedLeftData = useMemo(() => { - // console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); + console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) { - // console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환"); + console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환"); return leftData; } @@ -625,41 +605,6 @@ export const SplitPanelLayoutComponent: React.FC return result; }, []); - // 🆕 간단한 값 포맷팅 함수 (추가 탭용) - const formatValue = useCallback( - ( - value: any, - format?: { - type?: "number" | "currency" | "date" | "text"; - thousandSeparator?: boolean; - decimalPlaces?: number; - prefix?: string; - suffix?: string; - dateFormat?: string; - }, - ): string => { - if (value === null || value === undefined) return "-"; - - // 날짜 포맷 - if (format?.type === "date" || format?.dateFormat) { - return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); - } - - // 숫자 포맷 - if ( - format?.type === "number" || - format?.type === "currency" || - format?.thousandSeparator || - format?.decimalPlaces !== undefined - ) { - return formatNumberValue(value, format); - } - - return String(value); - }, - [formatDateValue, formatNumberValue], - ); - // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) const formatCellValue = useCallback( ( @@ -722,14 +667,6 @@ export const SplitPanelLayoutComponent: React.FC ); } - // 🆕 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값) - if (typeof value === "string" && value.startsWith("CATEGORY_")) { - const cachedLabel = categoryCodeLabels[value]; - if (cachedLabel) { - return {cachedLabel}; - } - } - // 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체) if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) { return formatDateValue(value, "YYYY-MM-DD"); @@ -751,7 +688,7 @@ export const SplitPanelLayoutComponent: React.FC // 일반 값 return String(value); }, - [formatDateValue, formatNumberValue, categoryCodeLabels], + [formatDateValue, formatNumberValue], ); // 좌측 데이터 로드 @@ -821,8 +758,8 @@ export const SplitPanelLayoutComponent: React.FC } }); - // console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns); - // console.log("🔗 [분할패널] configuredColumns:", configuredColumns); + console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns); + console.log("🔗 [분할패널] configuredColumns:", configuredColumns); const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, @@ -835,10 +772,10 @@ export const SplitPanelLayoutComponent: React.FC }); // 🔍 디버깅: API 응답 데이터의 키 확인 - // if (result.data && result.data.length > 0) { - // console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); - // console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); - // } + if (result.data && result.data.length > 0) { + console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); + console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); + } // 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; @@ -897,8 +834,7 @@ export const SplitPanelLayoutComponent: React.FC companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 }); - // result.data가 EntityJoinResponse의 실제 배열 필드 - const detail = result.data && result.data.length > 0 ? result.data[0] : null; + const detail = result.items && result.items.length > 0 ? result.items[0] : null; setRightData(detail); } else if (relationshipType === "join") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -968,68 +904,26 @@ export const SplitPanelLayoutComponent: React.FC return; } - // 🆕 엔티티 관계 자동 감지 로직 개선 - // 1. 설정된 keys가 있으면 사용 - // 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회 - let effectiveKeys = keys || []; - - if (effectiveKeys.length === 0 && leftTable && rightTableName) { - // 엔티티 관계 자동 감지 - console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName); - const { tableManagementApi } = await import("@/lib/api/tableManagement"); - const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName); - - if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) { - effectiveKeys = relResponse.data.relations.map((rel) => ({ - leftColumn: rel.leftColumn, - rightColumn: rel.rightColumn, - })); - console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys); - } - } - - if (effectiveKeys.length > 0 && leftTable) { + // 🆕 복합키 지원 + if (keys && keys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); - // 복합키 조건 생성 (다중 값 지원) - // 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함 - // 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 + // 복합키 조건 생성 const searchConditions: Record = {}; - effectiveKeys.forEach((key) => { + keys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - const leftValue = leftItem[key.leftColumn]; - // 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화 - if (typeof leftValue === "string") { - if (leftValue.includes(",")) { - // "2,3" 형태면 분리해서 배열로 - const values = leftValue - .split(",") - .map((v: string) => v.trim()) - .filter((v: string) => v); - searchConditions[key.rightColumn] = values; - console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values); - } else { - // 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로) - searchConditions[key.rightColumn] = [leftValue.trim()]; - console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]); - } - } else { - // 숫자나 다른 타입은 배열로 감싸기 - searchConditions[key.rightColumn] = [leftValue]; - console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]); - } + searchConditions[key.rightColumn] = leftItem[key.leftColumn]; } }); console.log("🔗 [분할패널] 복합키 조건:", searchConditions); - // 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달) + // 엔티티 조인 API로 데이터 조회 const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, size: 1000, - deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 }); @@ -1038,14 +932,10 @@ export const SplitPanelLayoutComponent: React.FC // 추가 dataFilter 적용 let filteredData = result.data || []; const dataFilter = componentConfig.rightPanel?.dataFilter; - // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용) - const filterConditions = dataFilter?.filters || dataFilter?.conditions || []; - if (dataFilter?.enabled && filterConditions.length > 0) { + if (dataFilter?.enabled && dataFilter.conditions?.length > 0) { filteredData = filteredData.filter((item: any) => { - return filterConditions.every((cond: any) => { - // columnName 또는 column 지원 - const columnName = cond.columnName || cond.column; - const value = item[columnName]; + return dataFilter.conditions.every((cond: any) => { + const value = item[cond.column]; const condValue = cond.value; switch (cond.operator) { case "equals": @@ -1054,12 +944,6 @@ export const SplitPanelLayoutComponent: React.FC return value !== condValue; case "contains": return String(value).includes(String(condValue)); - case "is_null": - case "NULL": - return value === null || value === undefined || value === ""; - case "is_not_null": - case "NOT NULL": - return value !== null && value !== undefined && value !== ""; default: return true; } @@ -1069,7 +953,7 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우 + // 단일키 (하위 호환성) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; @@ -1087,9 +971,6 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) - } else { - console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName); - setRightData([]); } } } @@ -1113,294 +994,23 @@ export const SplitPanelLayoutComponent: React.FC ], ); - // 🆕 카테고리 코드 라벨 로드 (rightData 변경 시) - useEffect(() => { - const loadCategoryCodeLabels = async () => { - if (!rightData) return; - - const categoryCodes = new Set(); - - // rightData가 배열인 경우 (조인 모드) - const dataArray = Array.isArray(rightData) ? rightData : [rightData]; - - dataArray.forEach((row: Record) => { - if (row) { - Object.values(row).forEach((value) => { - if (typeof value === "string" && value.startsWith("CATEGORY_")) { - categoryCodes.add(value); - } - }); - } - }); - - // 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외) - const newCodes = Array.from(categoryCodes).filter((code) => !categoryCodeLabels[code]); - - if (newCodes.length > 0) { - try { - console.log("🏷️ [SplitPanel] 카테고리 코드 라벨 조회:", newCodes); - const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes }); - if (response.data.success && response.data.data) { - console.log("🏷️ [SplitPanel] 카테고리 라벨 조회 결과:", response.data.data); - setCategoryCodeLabels((prev) => ({ - ...prev, - ...response.data.data, - })); - } - } catch (error) { - console.error("카테고리 라벨 조회 실패:", error); - } - } - }; - - loadCategoryCodeLabels(); - }, [rightData]); - - // 🆕 추가 탭 데이터 로딩 함수 - const loadTabData = useCallback( - async (tabIndex: number, leftItem: any) => { - console.log(`📥 loadTabData 호출됨: tabIndex=${tabIndex}`, { - leftItem: leftItem ? Object.keys(leftItem) : null, - additionalTabs: componentConfig.rightPanel?.additionalTabs?.length, - isDesignMode, - }); - - const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; - - console.log("📥 tabConfig:", { - tabIndex, - configIndex: tabIndex - 1, - tabConfig: tabConfig - ? { - tableName: tabConfig.tableName, - relation: tabConfig.relation, - dataFilter: tabConfig.dataFilter, - } - : null, - }); - - if (!tabConfig || !leftItem || isDesignMode) { - console.log("⚠️ loadTabData 중단:", { hasTabConfig: !!tabConfig, hasLeftItem: !!leftItem, isDesignMode }); - return; - } - - const tabTableName = tabConfig.tableName; - if (!tabTableName) return; - - setTabsLoading((prev) => ({ ...prev, [tabIndex]: true })); - try { - // 조인 키 확인 - const keys = tabConfig.relation?.keys; - const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; - const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; - - console.log(`🔑 [추가탭 ${tabIndex}] 조인 키 분석:`, { - hasRelation: !!tabConfig.relation, - keys, - leftColumn, - rightColumn, - willUseJoin: !!(leftColumn && rightColumn), - }); - - let resultData: any[] = []; - - if (leftColumn && rightColumn) { - // 조인 조건이 있는 경우 - const { entityJoinApi } = await import("@/lib/api/entityJoin"); - const searchConditions: Record = {}; - - if (keys && keys.length > 0) { - // 복합키 - keys.forEach((key) => { - if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - // operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색) - searchConditions[key.rightColumn] = { - value: leftItem[key.leftColumn], - operator: "equals", - }; - } - }); - } else { - // 단일키 - const leftValue = leftItem[leftColumn]; - if (leftValue !== undefined) { - // operator: "equals"를 추가하여 정확한 값 매칭 (entity 타입 컬럼에서 코드값으로 검색) - searchConditions[rightColumn] = { - value: leftValue, - operator: "equals", - }; - } - } - - console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions); - - const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { - search: searchConditions, - enableEntityJoin: true, - size: 1000, - }); - - resultData = result.data || []; - } else { - // 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭) - console.log(`📋 [추가탭 ${tabIndex}] 조인 없이 전체 데이터 조회: ${tabTableName}`); - const { entityJoinApi } = await import("@/lib/api/entityJoin"); - const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { - enableEntityJoin: true, - size: 1000, - }); - resultData = result.data || []; - console.log(`📋 [추가탭 ${tabIndex}] 전체 데이터 조회 결과:`, resultData.length); - } - - // 데이터 필터 적용 - const dataFilter = tabConfig.dataFilter; - // filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용) - const filterConditions = dataFilter?.filters || dataFilter?.conditions || []; - - console.log(`🔍 [추가탭 ${tabIndex}] 필터 설정:`, { - enabled: dataFilter?.enabled, - filterConditions, - dataBeforeFilter: resultData.length, - }); - - if (dataFilter?.enabled && filterConditions.length > 0) { - const beforeCount = resultData.length; - resultData = resultData.filter((item: any) => { - return filterConditions.every((cond: any) => { - // columnName 또는 column 지원 - const columnName = cond.columnName || cond.column; - const value = item[columnName]; - const condValue = cond.value; - - let result = true; - switch (cond.operator) { - case "equals": - result = value === condValue; - break; - case "notEquals": - result = value !== condValue; - break; - case "contains": - result = String(value).includes(String(condValue)); - break; - case "is_null": - case "NULL": - result = value === null || value === undefined || value === ""; - break; - case "is_not_null": - case "NOT NULL": - result = value !== null && value !== undefined && value !== ""; - break; - default: - result = true; - } - - // 첫 5개 항목만 로그 출력 - if (resultData.indexOf(item) < 5) { - console.log(` 필터 체크: ${columnName}=${value}, operator=${cond.operator}, result=${result}`); - } - - return result; - }); - }); - console.log(`🔍 [추가탭 ${tabIndex}] 필터 적용 후: ${beforeCount} → ${resultData.length}`); - } - - // 중복 제거 적용 - const deduplication = tabConfig.deduplication; - if (deduplication?.enabled && deduplication.groupByColumn) { - const groupedMap = new Map(); - resultData.forEach((item) => { - const key = String(item[deduplication.groupByColumn] || ""); - const existing = groupedMap.get(key); - if (!existing) { - groupedMap.set(key, item); - } else { - // keepStrategy에 따라 유지할 항목 결정 - const sortCol = deduplication.sortColumn || "start_date"; - const existingVal = existing[sortCol]; - const newVal = item[sortCol]; - if (deduplication.keepStrategy === "latest" && newVal > existingVal) { - groupedMap.set(key, item); - } else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) { - groupedMap.set(key, item); - } - } - }); - resultData = Array.from(groupedMap.values()); - } - - console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length); - setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); - } catch (error) { - console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error); - toast({ - title: "데이터 로드 실패", - description: "탭 데이터를 불러올 수 없습니다.", - variant: "destructive", - }); - } finally { - setTabsLoading((prev) => ({ ...prev, [tabIndex]: false })); - } - }, - [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], - ); - // 좌측 항목 선택 핸들러 const handleLeftItemSelect = useCallback( (item: any) => { setSelectedLeftItem(item); setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 - setTabsData({}); // 모든 탭 데이터 초기화 - - // 현재 활성 탭에 따라 데이터 로드 - if (activeTabIndex === 0) { - loadRightData(item); - } else { - loadTabData(activeTabIndex, item); - } + loadRightData(item); // 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택) const leftTableName = componentConfig.leftPanel?.tableName; if (leftTableName && !isDesignMode) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(leftTableName, [item]); - // console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item); + console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item); }); } }, - [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode], - ); - - // 🆕 탭 변경 핸들러 - const handleTabChange = useCallback( - (newTabIndex: number) => { - console.log(`🔄 탭 변경: ${activeTabIndex} → ${newTabIndex}`, { - selectedLeftItem: !!selectedLeftItem, - tabsData: Object.keys(tabsData), - hasTabData: !!tabsData[newTabIndex], - }); - - setActiveTabIndex(newTabIndex); - - // 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드 - if (selectedLeftItem) { - if (newTabIndex === 0) { - // 기본 탭: 우측 패널 데이터가 없으면 로드 - if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { - loadRightData(selectedLeftItem); - } - } else { - // 추가 탭: 항상 새로 로드 (필터 설정 변경 반영을 위해) - console.log(`🔄 추가 탭 ${newTabIndex} 데이터 로드 (항상 새로고침)`); - loadTabData(newTabIndex, selectedLeftItem); - } - } else { - console.log("⚠️ 좌측 항목이 선택되지 않아 탭 데이터를 로드하지 않음"); - } - }, - [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, activeTabIndex], + [loadRightData, componentConfig.leftPanel?.tableName, isDesignMode], ); // 우측 항목 확장/축소 토글 @@ -1594,7 +1204,7 @@ export const SplitPanelLayoutComponent: React.FC } }); setLeftColumnLabels(labels); - // console.log("✅ 좌측 컬럼 라벨 로드:", labels); + console.log("✅ 좌측 컬럼 라벨 로드:", labels); } catch (error) { console.error("좌측 테이블 컬럼 라벨 로드 실패:", error); } @@ -1623,7 +1233,7 @@ export const SplitPanelLayoutComponent: React.FC } }); setRightColumnLabels(labels); - // console.log("✅ 우측 컬럼 라벨 로드:", labels); + console.log("✅ 우측 컬럼 라벨 로드:", labels); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } @@ -1665,7 +1275,7 @@ export const SplitPanelLayoutComponent: React.FC }; }); mappings[columnName] = valueMap; - // console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap); + console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap); } } catch (error) { console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error); @@ -1703,7 +1313,7 @@ export const SplitPanelLayoutComponent: React.FC } }); - // console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); + console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); // 각 테이블에 대해 카테고리 매핑 로드 for (const tableName of tablesToLoad) { @@ -1796,22 +1406,36 @@ export const SplitPanelLayoutComponent: React.FC // 수정 버튼 핸들러 const handleEditClick = useCallback( - async (panel: "left" | "right", item: any) => { - // 🆕 현재 활성 탭의 설정 가져오기 - const currentTabConfig = - activeTabIndex === 0 - ? componentConfig.rightPanel - : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + (panel: "left" | "right", item: any) => { // 🆕 우측 패널 수정 버튼 설정 확인 - if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") { - const modalScreenId = currentTabConfig?.editButton?.modalScreenId; + if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") { + const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId; if (modalScreenId) { // 커스텀 모달 화면 열기 - const rightTableName = currentTabConfig?.tableName || ""; + const rightTableName = componentConfig.rightPanel?.tableName || ""; + + // Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) + let primaryKeyName = "id"; + let primaryKeyValue: any; + + if (item.id !== undefined && item.id !== null) { + primaryKeyName = "id"; + primaryKeyValue = item.id; + } else if (item.ID !== undefined && item.ID !== null) { + primaryKeyName = "ID"; + primaryKeyValue = item.ID; + } else { + // 첫 번째 필드를 Primary Key로 간주 + const firstKey = Object.keys(item)[0]; + primaryKeyName = firstKey; + primaryKeyValue = item[firstKey]; + } console.log("✅ 수정 모달 열기:", { tableName: rightTableName, + primaryKeyName, + primaryKeyValue, screenId: modalScreenId, fullItem: item, }); @@ -1822,99 +1446,23 @@ export const SplitPanelLayoutComponent: React.FC }); // 🆕 groupByColumns 추출 - const groupByColumns = currentTabConfig?.editButton?.groupByColumns || []; + const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { groupByColumns, - editButtonConfig: currentTabConfig?.editButton, + editButtonConfig: componentConfig.rightPanel?.editButton, hasGroupByColumns: groupByColumns.length > 0, }); - // 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출) - let allRelatedRecords = [item]; // 기본값: 현재 아이템만 - - if (groupByColumns.length > 0) { - // groupByColumns 값으로 검색 조건 생성 - const matchConditions: Record = {}; - groupByColumns.forEach((col: string) => { - if (item[col] !== undefined && item[col] !== null) { - matchConditions[col] = item[col]; - } - }); - - console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", { - 테이블: rightTableName, - 조건: matchConditions, - }); - - if (Object.keys(matchConditions).length > 0) { - // 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출) - try { - const { entityJoinApi } = await import("@/lib/api/entityJoin"); - - // 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확) - const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({ - id: `exact-${key}`, - columnName: key, - operator: "equals", - value: value, - valueType: "text", - })); - - console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters); - - const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { - // search 대신 dataFilter 사용 (정확 매칭) - dataFilter: { - enabled: true, - matchType: "all", - filters: exactMatchFilters, - }, - enableEntityJoin: true, - size: 1000, - // 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기) - deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" }, - }); - - // 🔍 디버깅: API 응답 구조 확인 - console.log("🔍 [SplitPanel] API 응답 전체:", result); - console.log("🔍 [SplitPanel] result.data:", result.data); - console.log("🔍 [SplitPanel] result 타입:", typeof result); - - // result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라) - const dataArray = Array.isArray(result) ? result : result.data || []; - - if (dataArray.length > 0) { - allRelatedRecords = dataArray; - console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", { - 조건: matchConditions, - 결과수: allRelatedRecords.length, - 레코드들: allRelatedRecords.map((r: any) => ({ - id: r.id, - supplier_item_code: r.supplier_item_code, - })), - }); - } else { - console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용"); - } - } catch (error) { - console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error); - allRelatedRecords = [item]; - } - } else { - console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용"); - } - } - - // 🔧 수정: URL 파라미터 대신 editData로 직접 전달 - // 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨 + // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: modalScreenId, - editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열) urlParams: { - mode: "edit", // 🆕 수정 모드 표시 + mode: "edit", + editId: primaryKeyValue, + tableName: rightTableName, ...(groupByColumns.length > 0 && { groupByColumns: JSON.stringify(groupByColumns), }), @@ -1923,10 +1471,10 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", { + console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { screenId: modalScreenId, - editData: allRelatedRecords, - recordCount: allRelatedRecords.length, + editId: primaryKeyValue, + tableName: rightTableName, groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", }); @@ -1940,7 +1488,7 @@ export const SplitPanelLayoutComponent: React.FC setEditModalFormData({ ...item }); setShowEditModal(true); }, - [componentConfig, activeTabIndex], + [componentConfig], ); // 수정 모달 저장 @@ -2040,18 +1588,13 @@ export const SplitPanelLayoutComponent: React.FC // 삭제 확인 const handleDeleteConfirm = useCallback(async () => { - // 🆕 현재 활성 탭의 설정 가져오기 - const currentTabConfig = - activeTabIndex === 0 - ? componentConfig.rightPanel - : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; - // 우측 패널 삭제 시 중계 테이블 확인 - let tableName = deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : currentTabConfig?.tableName; + let tableName = + deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName; // 우측 패널 + 중계 테이블 모드인 경우 - if (deleteModalPanel === "right" && currentTabConfig?.addConfig?.targetTable) { - tableName = currentTabConfig.addConfig.targetTable; + if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { + tableName = componentConfig.rightPanel.addConfig.targetTable; console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } @@ -2076,89 +1619,47 @@ export const SplitPanelLayoutComponent: React.FC try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); - // 🔍 그룹 삭제 설정 확인 (editButton.groupByColumns 또는 deduplication) - const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; - const deduplication = componentConfig.rightPanel?.dataFilter?.deduplication; - - console.log("🔍 삭제 설정 디버깅:", { + // 🔍 중복 제거 설정 디버깅 + console.log("🔍 중복 제거 디버깅:", { panel: deleteModalPanel, - groupByColumns, - deduplication, - deduplicationEnabled: deduplication?.enabled, + dataFilter: componentConfig.rightPanel?.dataFilter, + deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, + enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, }); let result; - // 🔧 우측 패널 삭제 시 그룹 삭제 조건 확인 - if (deleteModalPanel === "right") { - // 1. groupByColumns가 설정된 경우 (패널 설정에서 선택된 컬럼들) - if (groupByColumns.length > 0) { - const filterConditions: Record = {}; + // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 + if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { + const deduplication = componentConfig.rightPanel.dataFilter.deduplication; + const groupByColumn = deduplication.groupByColumn; - // 선택된 컬럼들의 값을 필터 조건으로 추가 - for (const col of groupByColumns) { - if (deleteModalItem[col] !== undefined && deleteModalItem[col] !== null) { - filterConditions[col] = deleteModalItem[col]; - } - } - - // 🔒 안전장치: 조인 모드에서 좌측 패널의 키 값도 필터 조건에 포함 - // (다른 거래처의 같은 품목이 삭제되는 것을 방지) - if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { - const leftColumn = componentConfig.rightPanel.join?.leftColumn; - const rightColumn = componentConfig.rightPanel.join?.rightColumn; - if (leftColumn && rightColumn && selectedLeftItem[leftColumn]) { - // rightColumn이 filterConditions에 없으면 추가 - if (!filterConditions[rightColumn]) { - filterConditions[rightColumn] = selectedLeftItem[leftColumn]; - console.log(`🔒 안전장치: ${rightColumn} = ${selectedLeftItem[leftColumn]} 추가`); - } - } - } - - // 필터 조건이 있으면 그룹 삭제 - if (Object.keys(filterConditions).length > 0) { - console.log(`🔗 그룹 삭제 (groupByColumns): ${groupByColumns.join(", ")} 기준`); - console.log("🗑️ 그룹 삭제 조건:", filterConditions); - - result = await dataApi.deleteGroupRecords(tableName, filterConditions); - } else { - // 필터 조건이 없으면 단일 삭제 - console.log("⚠️ groupByColumns 값이 없어 단일 삭제로 전환"); - result = await dataApi.deleteRecord(tableName, primaryKey); - } - } - // 2. 중복 제거(deduplication)가 활성화된 경우 - else if (deduplication?.enabled && deduplication?.groupByColumn) { - const groupByColumn = deduplication.groupByColumn; + if (groupByColumn && deleteModalItem[groupByColumn]) { const groupValue = deleteModalItem[groupByColumn]; + console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); - if (groupValue) { - console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); + // groupByColumn 값으로 필터링하여 삭제 + const filterConditions: Record = { + [groupByColumn]: groupValue, + }; - const filterConditions: Record = { - [groupByColumn]: groupValue, - }; - - // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) - if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { - const leftColumn = componentConfig.rightPanel.join.leftColumn; - const rightColumn = componentConfig.rightPanel.join.rightColumn; - filterConditions[rightColumn] = selectedLeftItem[leftColumn]; - } - - console.log("🗑️ 그룹 삭제 조건:", filterConditions); - result = await dataApi.deleteGroupRecords(tableName, filterConditions); - } else { - result = await dataApi.deleteRecord(tableName, primaryKey); + // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) + if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { + const leftColumn = componentConfig.rightPanel.join.leftColumn; + const rightColumn = componentConfig.rightPanel.join.rightColumn; + filterConditions[rightColumn] = selectedLeftItem[leftColumn]; } - } - // 3. 그 외: 단일 레코드 삭제 - else { + + console.log("🗑️ 그룹 삭제 조건:", filterConditions); + + // 그룹 삭제 API 호출 + result = await dataApi.deleteGroupRecords(tableName, filterConditions); + } else { + // 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey); } } else { - // 좌측 패널: 단일 레코드 삭제 + // 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey); } @@ -2181,12 +1682,7 @@ export const SplitPanelLayoutComponent: React.FC setRightData(null); } } else if (deleteModalPanel === "right" && selectedLeftItem) { - // 🆕 현재 활성 탭에 따라 새로고침 - if (activeTabIndex === 0) { - loadRightData(selectedLeftItem); - } else { - loadTabData(activeTabIndex, selectedLeftItem); - } + loadRightData(selectedLeftItem); } } else { toast({ @@ -2210,17 +1706,7 @@ export const SplitPanelLayoutComponent: React.FC variant: "destructive", }); } - }, [ - deleteModalPanel, - componentConfig, - deleteModalItem, - toast, - selectedLeftItem, - loadLeftData, - loadRightData, - activeTabIndex, - loadTabData, - ]); + }, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]); // 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가) const handleItemAddClick = useCallback( @@ -2460,7 +1946,7 @@ export const SplitPanelLayoutComponent: React.FC useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { - // console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); + console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); // 선택된 항목이 있으면 우측 패널도 새로고침 if (selectedLeftItem) { @@ -2562,7 +2048,7 @@ export const SplitPanelLayoutComponent: React.FC >
- {getTranslatedText(componentConfig.leftPanel?.langKey, componentConfig.leftPanel?.title || "좌측 패널")} + {componentConfig.leftPanel?.title || "좌측 패널"} {!isDesignMode && componentConfig.leftPanel?.showAdd && (