diff --git a/PLAN.MD b/PLAN.MD index 0ca6521d..271d0af1 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,7 +1,7 @@ -# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리) +# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정) ## 개요 -화면 관리 시스템의 복제 및 삭제 기능을 전면 개선하여, 단일 화면 복제, 그룹(폴더) 전체 복제, 정렬 순서 유지, 일괄 이름 변경 등 다양한 고급 기능을 지원합니다. +화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다. ## 핵심 기능 @@ -15,47 +15,54 @@ ### 2. 그룹(폴더) 전체 복제 - [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제 - [x] 정렬 순서(display_order) 유지 - - 그룹 생성 시 원본 display_order 전달 - - 화면 추가 시 원본 display_order 유지 - - 하위 그룹들 display_order 순으로 정렬 후 복제 - [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시 -- [x] 정렬 순서 입력 필드 추가 (사용자가 직접 수정 가능) -- [x] 원본 그룹 정보 표시 개선 - - 직접 포함 화면 수 - - 하위 그룹 수 - - 복제될 총 화면 수 (하위 그룹 포함) +- [x] 정렬 순서 입력 필드 추가 +- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만 +- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto) ### 3. 고급 옵션: 이름 일괄 변경 -- [x] 삭제할 텍스트 지정 (모든 폴더/화면 이름에서 제거) -- [x] 추가할 접미사 지정 (기본값: " (복제)") +- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace) - [x] 미리보기 기능 ### 4. 삭제 기능 - [x] 단일 화면 삭제 (휴지통으로 이동) -- [x] 그룹 삭제 시 옵션 선택 - - "화면도 함께 삭제" 체크박스 - - 체크 시: 그룹 + 포함된 화면 모두 삭제 - - 미체크 시: 화면은 "미분류"로 이동 +- [x] 그룹 삭제 (화면 함께 삭제 옵션) +- [x] 삭제 시 로딩 프로그레스 바 표시 -### 5. 회사 코드 지원 (최고 관리자) +### 5. 화면 수정 기능 +- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경 +- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정 + +### 6. 테이블 설정 기능 (TableSettingModal) +- [x] 화면 설정 모달에 "테이블 설정" 탭 추가 +- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화 + - 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화 + - 코드→다른 타입: codeCategory, codeValue 초기화 +- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동) +- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시) + +### 7. 회사 코드 지원 (최고 관리자) - [x] 대상 회사 선택 가능 -- [x] 복제된 그룹/화면에 선택한 회사 코드 적용 +- [x] 상위 그룹 선택 시 자동 회사 코드 설정 ## 관련 파일 -- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 (화면/그룹 통합) +- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 - `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 -- `frontend/lib/api/screen.ts` - 화면 API (복제, 삭제) +- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달 +- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함) +- `frontend/lib/api/screen.ts` - 화면 API - `frontend/lib/api/screenGroup.ts` - 그룹 API +- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API ## 진행 상태 - [완료] 단일 화면 복제 + 새로고침 - [완료] 그룹 전체 복제 (재귀적) -- [완료] 정렬 순서(display_order) 유지 -- [완료] 대분류 경고 문구 -- [완료] 정렬 순서 입력 필드 -- [완료] 고급 옵션: 이름 일괄 변경 -- [완료] 단일 화면 삭제 -- [완료] 그룹 삭제 (화면 함께 삭제 옵션) +- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace) +- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스 +- [완료] 화면 수정 (이름/그룹/역할/순서) +- [완료] 테이블 설정 탭 추가 +- [완료] 입력 타입 변경 시 관련 필드 초기화 +- [완료] 그룹 복제 모달 스크롤 문제 수정 --- 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..b89ef902 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,23 +1,24 @@ 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"; +import { + syncScreenGroupsToMenu, + syncMenuToScreenGroups, + getSyncStatus, + syncAllCompanies, +} from "../services/menuScreenSyncService"; // 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 +90,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 +136,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 +197,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 +210,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,11 +299,36 @@ export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response }; // 화면 그룹 삭제 -export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => { +export const deleteScreenGroup = async (req: Request, res: Response) => { + const client = await pool.connect(); try { const { id } = req.params; - const companyCode = req.user!.companyCode; + const companyCode = (req.user as any).companyCode; + await client.query('BEGIN'); + + // 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상) + const childGroupsResult = await client.query(` + WITH RECURSIVE child_groups AS ( + SELECT id FROM screen_groups WHERE id = $1 + UNION ALL + SELECT sg.id FROM screen_groups sg + JOIN child_groups cg ON sg.parent_group_id = cg.id + ) + SELECT id FROM child_groups + `, [id]); + const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id); + + // 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리 + if (groupIdsToDelete.length > 0) { + await client.query(` + UPDATE menu_info + SET screen_group_id = NULL + WHERE screen_group_id = ANY($1::int[]) + `, [groupIdsToDelete]); + } + + // 3. screen_groups 삭제 let query = `DELETE FROM screen_groups WHERE id = $1`; const params: any[] = [id]; @@ -354,18 +339,24 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response query += " RETURNING id"; - const result = await pool.query(query, params); + const result = await client.query(query, params); if (result.rows.length === 0) { + await client.query('ROLLBACK'); return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없거나 권한이 없습니다." }); } - logger.info("화면 그룹 삭제", { companyCode, groupId: id }); + await client.query('COMMIT'); + + logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length }); res.json({ success: true, message: "화면 그룹이 삭제되었습니다." }); } catch (error: any) { + await client.query('ROLLBACK'); logger.error("화면 그룹 삭제 실패:", error); res.status(500).json({ success: false, message: "화면 그룹 삭제에 실패했습니다.", error: error.message }); + } finally { + client.release(); } }; @@ -375,10 +366,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 +406,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 +437,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 +476,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 +517,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 +558,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 +603,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 +637,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 +687,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 +726,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 +769,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 +803,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 +852,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 +885,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 +920,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]; @@ -2060,3 +2051,202 @@ export const getScreenSubTables = async (req: Request, res: Response) => { } }; + +// ============================================================ +// 메뉴-화면그룹 동기화 API +// ============================================================ + +/** + * 화면관리 → 메뉴 동기화 + * screen_groups를 menu_info로 동기화 + */ +export const syncScreenGroupsToMenuController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { targetCompanyCode } = req.body; + + // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 + let companyCode = userCompanyCode; + if (userCompanyCode === "*" && targetCompanyCode) { + companyCode = targetCompanyCode; + } + + // 최고 관리자(*)는 회사를 지정해야 함 + if (companyCode === "*") { + return res.status(400).json({ + success: false, + message: "동기화할 회사를 선택해주세요.", + }); + } + + logger.info("화면관리 → 메뉴 동기화 요청", { companyCode, userId }); + + const result = await syncScreenGroupsToMenu(companyCode, userId); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: "동기화 중 오류가 발생했습니다.", + errors: result.errors, + }); + } + + res.json({ + success: true, + message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`, + data: result, + }); + } catch (error: any) { + logger.error("화면관리 → 메뉴 동기화 실패:", error); + res.status(500).json({ + success: false, + message: "동기화에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 메뉴 → 화면관리 동기화 + * menu_info를 screen_groups로 동기화 + */ +export const syncMenuToScreenGroupsController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + const { targetCompanyCode } = req.body; + + // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 + let companyCode = userCompanyCode; + if (userCompanyCode === "*" && targetCompanyCode) { + companyCode = targetCompanyCode; + } + + // 최고 관리자(*)는 회사를 지정해야 함 + if (companyCode === "*") { + return res.status(400).json({ + success: false, + message: "동기화할 회사를 선택해주세요.", + }); + } + + logger.info("메뉴 → 화면관리 동기화 요청", { companyCode, userId }); + + const result = await syncMenuToScreenGroups(companyCode, userId); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: "동기화 중 오류가 발생했습니다.", + errors: result.errors, + }); + } + + res.json({ + success: true, + message: `동기화 완료: 생성 ${result.created}개, 연결 ${result.linked}개, 스킵 ${result.skipped}개`, + data: result, + }); + } catch (error: any) { + logger.error("메뉴 → 화면관리 동기화 실패:", error); + res.status(500).json({ + success: false, + message: "동기화에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 동기화 상태 조회 + */ +export const getSyncStatusController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const { targetCompanyCode } = req.query; + + // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 + let companyCode = userCompanyCode; + if (userCompanyCode === "*" && targetCompanyCode) { + companyCode = targetCompanyCode as string; + } + + // 최고 관리자(*)는 회사를 지정해야 함 + if (companyCode === "*") { + return res.status(400).json({ + success: false, + message: "조회할 회사를 선택해주세요.", + }); + } + + const status = await getSyncStatus(companyCode); + + res.json({ + success: true, + data: status, + }); + } catch (error: any) { + logger.error("동기화 상태 조회 실패:", error); + res.status(500).json({ + success: false, + message: "동기화 상태 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 전체 회사 동기화 + * 모든 회사에 대해 양방향 동기화 수행 (최고 관리자만) + */ +export const syncAllCompaniesController = async (req: Request, res: Response) => { + try { + const userCompanyCode = (req.user as any).companyCode; + const userId = (req.user as any).userId; + + // 최고 관리자만 전체 동기화 가능 + if (userCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "전체 동기화는 최고 관리자만 수행할 수 있습니다.", + }); + } + + logger.info("전체 회사 동기화 요청", { userId }); + + const result = await syncAllCompanies(userId); + + if (!result.success) { + return res.status(500).json({ + success: false, + message: "전체 동기화 중 오류가 발생했습니다.", + }); + } + + // 결과 요약 + const totalCreated = result.results.reduce((sum, r) => sum + r.created, 0); + const totalLinked = result.results.reduce((sum, r) => sum + r.linked, 0); + + res.json({ + success: true, + message: `전체 동기화 완료: ${result.totalCompanies}개 회사 중 ${result.successCount}개 성공`, + data: { + totalCompanies: result.totalCompanies, + successCount: result.successCount, + failedCount: result.failedCount, + totalCreated, + totalLinked, + details: result.results, + }, + }); + } catch (error: any) { + logger.error("전체 회사 동기화 실패:", error); + res.status(500).json({ + success: false, + message: "전체 동기화에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 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/backend-node/src/routes/screenGroupRoutes.ts b/backend-node/src/routes/screenGroupRoutes.ts index d4980fe8..614e6d61 100644 --- a/backend-node/src/routes/screenGroupRoutes.ts +++ b/backend-node/src/routes/screenGroupRoutes.ts @@ -31,6 +31,11 @@ import { getMultipleScreenLayoutSummary, // 화면 서브 테이블 관계 getScreenSubTables, + // 메뉴-화면그룹 동기화 + syncScreenGroupsToMenuController, + syncMenuToScreenGroupsController, + getSyncStatusController, + syncAllCompaniesController, } from "../controllers/screenGroupController"; const router = Router(); @@ -89,6 +94,18 @@ router.post("/layout-summary/batch", getMultipleScreenLayoutSummary); // ============================================================ router.post("/sub-tables/batch", getScreenSubTables); +// ============================================================ +// 메뉴-화면그룹 동기화 +// ============================================================ +// 동기화 상태 조회 +router.get("/sync/status", getSyncStatusController); +// 화면관리 → 메뉴 동기화 +router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController); +// 메뉴 → 화면관리 동기화 +router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController); +// 전체 회사 동기화 (최고 관리자만) +router.post("/sync/all", syncAllCompaniesController); + export default router; diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 75c57673..8c6e63f0 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -254,7 +254,10 @@ class DataService { key !== "limit" && key !== "offset" && key !== "orderBy" && - key !== "userLang" + key !== "userLang" && + key !== "page" && + key !== "pageSize" && + key !== "size" ) { // 컬럼명 검증 (SQL 인젝션 방지) if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { diff --git a/backend-node/src/services/menuScreenSyncService.ts b/backend-node/src/services/menuScreenSyncService.ts new file mode 100644 index 00000000..13c77ed6 --- /dev/null +++ b/backend-node/src/services/menuScreenSyncService.ts @@ -0,0 +1,939 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +/** + * 메뉴-화면그룹 동기화 서비스 + * + * 양방향 동기화: + * 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화 + * 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화 + */ + +// ============================================================ +// 타입 정의 +// ============================================================ + +interface SyncResult { + success: boolean; + created: number; + linked: number; + skipped: number; + errors: string[]; + details: SyncDetail[]; +} + +interface SyncDetail { + action: 'created' | 'linked' | 'skipped' | 'error'; + sourceName: string; + sourceId: number | string; + targetId?: number | string; + reason?: string; +} + +// ============================================================ +// 화면관리 → 메뉴 동기화 +// ============================================================ + +/** + * screen_groups를 menu_info로 동기화 + * + * 로직: + * 1. 해당 회사의 screen_groups 조회 (폴더 구조) + * 2. 이미 menu_objid가 연결된 것은 제외 + * 3. 이름으로 기존 menu_info 매칭 시도 + * - 매칭되면: 양쪽에 연결 ID 업데이트 + * - 매칭 안되면: menu_info에 새로 생성 + * 4. 계층 구조(parent) 유지 + */ +export async function syncScreenGroupsToMenu( + companyCode: string, + userId: string +): Promise { + const result: SyncResult = { + success: true, + created: 0, + linked: 0, + skipped: 0, + errors: [], + details: [], + }; + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId }); + + // 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것) + const screenGroupsQuery = ` + SELECT + sg.id, + sg.group_name, + sg.group_code, + sg.parent_group_id, + sg.group_level, + sg.display_order, + sg.description, + sg.icon, + sg.menu_objid, + -- 부모 그룹의 menu_objid도 조회 (계층 연결용) + parent.menu_objid as parent_menu_objid + FROM screen_groups sg + LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id + WHERE sg.company_code = $1 + ORDER BY sg.group_level ASC, sg.display_order ASC + `; + const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]); + + // 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1) + // 경로 기반 매칭을 위해 부모 이름도 조회 + const existingMenusQuery = ` + SELECT + m.objid, + m.menu_name_kor, + m.parent_obj_id, + m.screen_group_id, + p.menu_name_kor as parent_name + FROM menu_info m + LEFT JOIN menu_info p ON m.parent_obj_id = p.objid + WHERE m.company_code = $1 AND m.menu_type = 1 + `; + const existingMenusResult = await client.query(existingMenusQuery, [companyCode]); + + // 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만) + // 단순 이름 매칭도 유지 (하위 호환) + const menuByPath: Map = new Map(); + const menuByName: Map = new Map(); + existingMenusResult.rows.forEach((menu: any) => { + if (!menu.screen_group_id) { + const menuName = menu.menu_name_kor?.trim().toLowerCase() || ''; + const parentName = menu.parent_name?.trim().toLowerCase() || ''; + const pathKey = parentName ? `${parentName}>${menuName}` : menuName; + + menuByPath.set(pathKey, menu); + // 단순 이름 매핑은 첫 번째 것만 (중복 방지) + if (!menuByName.has(menuName)) { + menuByName.set(menuName, menu); + } + } + }); + + // 모든 메뉴의 objid 집합 (삭제 확인용) + const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid))); + + // 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴) + // 없으면 생성 + let userMenuRootObjid: number | null = null; + const rootMenuQuery = ` + SELECT objid FROM menu_info + WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0 + ORDER BY seq ASC + LIMIT 1 + `; + const rootMenuResult = await client.query(rootMenuQuery, [companyCode]); + + if (rootMenuResult.rows.length > 0) { + userMenuRootObjid = Number(rootMenuResult.rows[0].objid); + } else { + // 루트 메뉴가 없으면 생성 + const newObjid = Date.now(); + const createRootQuery = ` + INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) + VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'Y') + RETURNING objid + `; + const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); + userMenuRootObjid = Number(createRootResult.rows[0].objid); + logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid }); + } + + // 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해) + const groupToMenuMap: Map = new Map(); + + // screen_groups의 부모 이름 조회를 위한 매핑 + const groupIdToName: Map = new Map(); + screenGroupsResult.rows.forEach((g: any) => { + groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || ''); + }); + + // 5. 각 screen_group 처리 + for (const group of screenGroupsResult.rows) { + const groupId = group.id; + const groupName = group.group_name?.trim(); + const groupNameLower = groupName?.toLowerCase() || ''; + + // 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인 + if (group.menu_objid) { + const menuExists = existingMenuObjids.has(Number(group.menu_objid)); + + if (menuExists) { + // 메뉴가 존재하면 스킵 + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: groupName, + sourceId: groupId, + targetId: group.menu_objid, + reason: '이미 메뉴와 연결됨', + }); + groupToMenuMap.set(groupId, Number(group.menu_objid)); + continue; + } else { + // 메뉴가 삭제되었으면 연결 해제하고 재생성 + logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid }); + await client.query( + `UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`, + [groupId] + ); + // 계속 진행하여 재생성 또는 재연결 + } + } + + // 부모 그룹 이름 조회 (경로 기반 매칭용) + const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : ''; + const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower; + + // 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭) + let matchedMenu = menuByPath.get(pathKey); + if (!matchedMenu) { + // 경로 매칭 실패시 이름으로 시도 (하위 호환) + matchedMenu = menuByName.get(groupNameLower); + } + + if (matchedMenu) { + // 매칭된 메뉴와 연결 + const menuObjid = Number(matchedMenu.objid); + + // screen_groups에 menu_objid 업데이트 + await client.query( + `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, + [menuObjid, groupId] + ); + + // menu_info에 screen_group_id 업데이트 + await client.query( + `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, + [groupId, menuObjid] + ); + + groupToMenuMap.set(groupId, menuObjid); + result.linked++; + result.details.push({ + action: 'linked', + sourceName: groupName, + sourceId: groupId, + targetId: menuObjid, + }); + + // 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지) + menuByPath.delete(pathKey); + menuByName.delete(groupNameLower); + + } else { + // 새 메뉴 생성 + const newObjid = Date.now() + groupId; // 고유 ID 보장 + + // 부모 메뉴 objid 결정 + let parentMenuObjid = userMenuRootObjid; + if (group.parent_group_id && group.parent_menu_objid) { + parentMenuObjid = Number(group.parent_menu_objid); + } else if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) { + parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!; + } + + // 같은 부모 아래에서 가장 높은 seq 조회 후 +1 + let nextSeq = 1; + const maxSeqQuery = ` + SELECT COALESCE(MAX(seq), 0) + 1 as next_seq + FROM menu_info + WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1 + `; + const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]); + if (maxSeqResult.rows.length > 0) { + nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1; + } + + // menu_info에 삽입 + const insertMenuQuery = ` + INSERT INTO menu_info ( + objid, parent_obj_id, menu_name_kor, menu_name_eng, + seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc + ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'Y', $8, $9) + RETURNING objid + `; + await client.query(insertMenuQuery, [ + newObjid, + parentMenuObjid, + groupName, + group.group_code || groupName, + nextSeq, + companyCode, + userId, + groupId, + group.description || null, + ]); + + // screen_groups에 menu_objid 업데이트 + await client.query( + `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, + [newObjid, groupId] + ); + + groupToMenuMap.set(groupId, newObjid); + result.created++; + result.details.push({ + action: 'created', + sourceName: groupName, + sourceId: groupId, + targetId: newObjid, + }); + } + } + + await client.query('COMMIT'); + + logger.info("화면관리 → 메뉴 동기화 완료", { + companyCode, + created: result.created, + linked: result.linked, + skipped: result.skipped + }); + + return result; + + } catch (error: any) { + await client.query('ROLLBACK'); + logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message }); + result.success = false; + result.errors.push(error.message); + return result; + } finally { + client.release(); + } +} + + +// ============================================================ +// 메뉴 → 화면관리 동기화 +// ============================================================ + +/** + * menu_info를 screen_groups로 동기화 + * + * 로직: + * 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회 + * 2. 이미 screen_group_id가 연결된 것은 제외 + * 3. 이름으로 기존 screen_groups 매칭 시도 + * - 매칭되면: 양쪽에 연결 ID 업데이트 + * - 매칭 안되면: screen_groups에 새로 생성 (폴더로) + * 4. 계층 구조(parent) 유지 + */ +export async function syncMenuToScreenGroups( + companyCode: string, + userId: string +): Promise { + const result: SyncResult = { + success: true, + created: 0, + linked: 0, + skipped: 0, + errors: [], + details: [], + }; + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId }); + + // 0. 회사 이름 조회 (회사 폴더 찾기/생성용) + const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`; + const companyNameResult = await client.query(companyNameQuery, [companyCode]); + const companyName = companyNameResult.rows[0]?.company_name || companyCode; + + // 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1) + const menusQuery = ` + SELECT + m.objid, + m.menu_name_kor, + m.menu_name_eng, + m.parent_obj_id, + m.seq, + m.menu_url, + m.menu_desc, + m.screen_group_id, + -- 부모 메뉴의 screen_group_id도 조회 (계층 연결용) + parent.screen_group_id as parent_screen_group_id + FROM menu_info m + LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid + WHERE m.company_code = $1 AND m.menu_type = 1 + ORDER BY + CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END, + m.parent_obj_id, + m.seq + `; + const menusResult = await client.query(menusQuery, [companyCode]); + + // 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회) + const existingGroupsQuery = ` + SELECT + g.id, + g.group_name, + g.menu_objid, + g.parent_group_id, + p.group_name as parent_name + FROM screen_groups g + LEFT JOIN screen_groups p ON g.parent_group_id = p.id + WHERE g.company_code = $1 + `; + const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]); + + // 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만) + // 단순 이름 매칭도 유지 (하위 호환) + const groupByPath: Map = new Map(); + const groupByName: Map = new Map(); + existingGroupsResult.rows.forEach((group: any) => { + if (!group.menu_objid) { + const groupName = group.group_name?.trim().toLowerCase() || ''; + const parentName = group.parent_name?.trim().toLowerCase() || ''; + const pathKey = parentName ? `${parentName}>${groupName}` : groupName; + + groupByPath.set(pathKey, group); + // 단순 이름 매핑은 첫 번째 것만 (중복 방지) + if (!groupByName.has(groupName)) { + groupByName.set(groupName, group); + } + } + }); + + // 모든 그룹의 id 집합 (삭제 확인용) + const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id))); + + // 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더) + let companyFolderId: number | null = null; + const companyFolderQuery = ` + SELECT id FROM screen_groups + WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0 + ORDER BY id ASC + LIMIT 1 + `; + const companyFolderResult = await client.query(companyFolderQuery, [companyCode]); + + if (companyFolderResult.rows.length > 0) { + companyFolderId = companyFolderResult.rows[0].id; + logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName }); + } else { + // 회사 폴더가 없으면 생성 + // 루트 레벨에서 가장 높은 display_order 조회 후 +1 + let nextRootOrder = 1; + const maxRootOrderQuery = ` + SELECT COALESCE(MAX(display_order), 0) + 1 as next_order + FROM screen_groups + WHERE parent_group_id IS NULL + `; + const maxRootOrderResult = await client.query(maxRootOrderQuery); + if (maxRootOrderResult.rows.length > 0) { + nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1; + } + + const createFolderQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, parent_group_id, group_level, + display_order, company_code, writer, hierarchy_path + ) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/') + RETURNING id + `; + const createFolderResult = await client.query(createFolderQuery, [ + companyName, + companyCode.toLowerCase(), + nextRootOrder, + companyCode, + userId, + ]); + companyFolderId = createFolderResult.rows[0].id; + + // hierarchy_path 업데이트 + await client.query( + `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, + [`/${companyFolderId}/`, companyFolderId] + ); + + logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName }); + } + + // 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해) + const menuToGroupMap: Map = new Map(); + + // 부모 메뉴 중 이미 screen_group_id가 있는 것 등록 + menusResult.rows.forEach((menu: any) => { + if (menu.screen_group_id) { + menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id)); + } + }); + + // 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑 + let rootMenuObjid: number | null = null; + for (const menu of menusResult.rows) { + if (Number(menu.parent_obj_id) === 0) { + rootMenuObjid = Number(menu.objid); + // 루트 메뉴는 회사 폴더와 연결 + if (companyFolderId) { + menuToGroupMap.set(rootMenuObjid, companyFolderId); + } + break; + } + } + + // 5. 각 메뉴 처리 + for (const menu of menusResult.rows) { + const menuObjid = Number(menu.objid); + const menuName = menu.menu_name_kor?.trim(); + + // 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨) + if (Number(menu.parent_obj_id) === 0) { + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: menuName, + sourceId: menuObjid, + targetId: companyFolderId || undefined, + reason: '루트 메뉴 → 회사 폴더와 매핑됨', + }); + continue; + } + + // 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인 + if (menu.screen_group_id) { + const groupExists = existingGroupIds.has(Number(menu.screen_group_id)); + + if (groupExists) { + // 그룹이 존재하면 스킵 + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: menuName, + sourceId: menuObjid, + targetId: menu.screen_group_id, + reason: '이미 화면그룹과 연결됨', + }); + menuToGroupMap.set(menuObjid, Number(menu.screen_group_id)); + continue; + } else { + // 그룹이 삭제되었으면 연결 해제하고 재생성 + logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id }); + await client.query( + `UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`, + [menuObjid] + ); + // 계속 진행하여 재생성 또는 재연결 + } + } + + const menuNameLower = menuName?.toLowerCase() || ''; + + // 부모 메뉴 이름 조회 (경로 기반 매칭용) + const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id)); + const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || ''; + const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower; + + // 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭) + let matchedGroup = groupByPath.get(pathKey); + if (!matchedGroup) { + // 경로 매칭 실패시 이름으로 시도 (하위 호환) + matchedGroup = groupByName.get(menuNameLower); + } + + if (matchedGroup) { + // 매칭된 그룹과 연결 + const groupId = Number(matchedGroup.id); + + try { + // menu_info에 screen_group_id 업데이트 + await client.query( + `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, + [groupId, menuObjid] + ); + + // screen_groups에 menu_objid 업데이트 + await client.query( + `UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`, + [menuObjid, groupId] + ); + + menuToGroupMap.set(menuObjid, groupId); + result.linked++; + result.details.push({ + action: 'linked', + sourceName: menuName, + sourceId: menuObjid, + targetId: groupId, + }); + + // 매칭된 그룹은 Map에서 제거 (중복 매칭 방지) + groupByPath.delete(pathKey); + groupByName.delete(menuNameLower); + } catch (linkError: any) { + logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack }); + throw linkError; + } + + } else { + // 새 screen_group 생성 + // 부모 그룹 ID 결정 + let parentGroupId: number | null = null; + let groupLevel = 1; // 기본값은 1 (회사 폴더 아래) + + // 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것) + if (menuToGroupMap.has(Number(menu.parent_obj_id))) { + parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!; + } + // 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용 + else if (Number(menu.parent_obj_id) === rootMenuObjid) { + parentGroupId = companyFolderId; + } + // 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용 + else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) { + parentGroupId = Number(menu.parent_screen_group_id); + } + + // 부모 그룹의 레벨 조회 + if (parentGroupId) { + const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`; + const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]); + if (parentLevelResult.rows.length > 0) { + groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1; + } + } + + // 같은 부모 아래에서 가장 높은 display_order 조회 후 +1 + let nextDisplayOrder = 1; + const maxOrderQuery = parentGroupId + ? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2` + : `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`; + const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode]; + const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams); + if (maxOrderResult.rows.length > 0) { + nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1; + } + + // group_code 생성 (영문명 또는 이름 기반) + const groupCode = (menu.menu_name_eng || menuName || 'group') + .replace(/\s+/g, '_') + .toLowerCase() + .substring(0, 50); + + // screen_groups에 삽입 + const insertGroupQuery = ` + INSERT INTO screen_groups ( + group_name, group_code, parent_group_id, group_level, + display_order, company_code, writer, menu_objid, description + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id + `; + + let newGroupId: number; + try { + logger.info("새 그룹 생성 시도", { + menuName, + menuObjid, + groupCode: groupCode + '_' + menuObjid, + parentGroupId, + groupLevel, + nextDisplayOrder, + companyCode, + }); + + const insertResult = await client.query(insertGroupQuery, [ + menuName, + groupCode + '_' + menuObjid, // 고유성 보장 + parentGroupId, + groupLevel, + nextDisplayOrder, + companyCode, + userId, + menuObjid, + menu.menu_desc || null, + ]); + + newGroupId = insertResult.rows[0].id; + } catch (insertError: any) { + logger.error("그룹 생성 중 에러", { + menuName, + menuObjid, + parentGroupId, + groupLevel, + error: insertError.message, + stack: insertError.stack, + code: insertError.code, + detail: insertError.detail, + }); + throw insertError; + } + + // hierarchy_path 업데이트 + let hierarchyPath = `/${newGroupId}/`; + if (parentGroupId) { + const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`; + const parentPathResult = await client.query(parentPathQuery, [parentGroupId]); + if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) { + hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/'); + } + } + await client.query( + `UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`, + [hierarchyPath, newGroupId] + ); + + // menu_info에 screen_group_id 업데이트 + await client.query( + `UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`, + [newGroupId, menuObjid] + ); + + menuToGroupMap.set(menuObjid, newGroupId); + result.created++; + result.details.push({ + action: 'created', + sourceName: menuName, + sourceId: menuObjid, + targetId: newGroupId, + }); + } + } + + await client.query('COMMIT'); + + logger.info("메뉴 → 화면관리 동기화 완료", { + companyCode, + created: result.created, + linked: result.linked, + skipped: result.skipped + }); + + return result; + + } catch (error: any) { + await client.query('ROLLBACK'); + logger.error("메뉴 → 화면관리 동기화 실패", { + companyCode, + error: error.message, + stack: error.stack, + code: error.code, + detail: error.detail, + }); + result.success = false; + result.errors.push(error.message); + return result; + } finally { + client.release(); + } +} + + +// ============================================================ +// 동기화 상태 조회 +// ============================================================ + +/** + * 동기화 상태 조회 + * + * - 연결된 항목 수 + * - 연결 안 된 항목 수 + * - 양방향 비교 + */ +export async function getSyncStatus(companyCode: string): Promise<{ + screenGroups: { total: number; linked: number; unlinked: number }; + menuItems: { total: number; linked: number; unlinked: number }; + potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>; +}> { + // screen_groups 상태 + const sgQuery = ` + SELECT + COUNT(*) as total, + COUNT(menu_objid) as linked + FROM screen_groups + WHERE company_code = $1 + `; + const sgResult = await pool.query(sgQuery, [companyCode]); + + // menu_info 상태 (사용자 메뉴만, 루트 제외) + const menuQuery = ` + SELECT + COUNT(*) as total, + COUNT(screen_group_id) as linked + FROM menu_info + WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0 + `; + const menuResult = await pool.query(menuQuery, [companyCode]); + + // 이름이 같은 잠재적 매칭 후보 조회 + const matchQuery = ` + SELECT + m.menu_name_kor as menu_name, + sg.group_name + FROM menu_info m + JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name)) + WHERE m.company_code = $1 + AND sg.company_code = $1 + AND m.menu_type = 1 + AND m.screen_group_id IS NULL + AND sg.menu_objid IS NULL + LIMIT 10 + `; + const matchResult = await pool.query(matchQuery, [companyCode]); + + const sgTotal = parseInt(sgResult.rows[0].total); + const sgLinked = parseInt(sgResult.rows[0].linked); + const menuTotal = parseInt(menuResult.rows[0].total); + const menuLinked = parseInt(menuResult.rows[0].linked); + + return { + screenGroups: { + total: sgTotal, + linked: sgLinked, + unlinked: sgTotal - sgLinked, + }, + menuItems: { + total: menuTotal, + linked: menuLinked, + unlinked: menuTotal - menuLinked, + }, + potentialMatches: matchResult.rows.map((row: any) => ({ + menuName: row.menu_name, + groupName: row.group_name, + similarity: 'exact', + })), + }; +} + + +// ============================================================ +// 전체 동기화 (모든 회사) +// ============================================================ + +interface AllCompaniesSyncResult { + success: boolean; + totalCompanies: number; + successCount: number; + failedCount: number; + results: Array<{ + companyCode: string; + companyName: string; + direction: 'screens-to-menus' | 'menus-to-screens'; + created: number; + linked: number; + skipped: number; + success: boolean; + error?: string; + }>; +} + +/** + * 모든 회사에 대해 양방향 동기화 수행 + * + * 로직: + * 1. 모든 회사 조회 + * 2. 각 회사별로 양방향 동기화 수행 + * - 화면관리 → 메뉴 동기화 + * - 메뉴 → 화면관리 동기화 + * 3. 결과 집계 + */ +export async function syncAllCompanies( + userId: string +): Promise { + const result: AllCompaniesSyncResult = { + success: true, + totalCompanies: 0, + successCount: 0, + failedCount: 0, + results: [], + }; + + try { + logger.info("전체 동기화 시작", { userId }); + + // 모든 회사 조회 (최고 관리자 전용 회사 제외) + const companiesQuery = ` + SELECT company_code, company_name + FROM company_mng + WHERE company_code != '*' + ORDER BY company_name + `; + const companiesResult = await pool.query(companiesQuery); + + result.totalCompanies = companiesResult.rows.length; + + // 각 회사별로 양방향 동기화 + for (const company of companiesResult.rows) { + const companyCode = company.company_code; + const companyName = company.company_name; + + try { + // 1. 화면관리 → 메뉴 동기화 + const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId); + result.results.push({ + companyCode, + companyName, + direction: 'screens-to-menus', + created: screensToMenusResult.created, + linked: screensToMenusResult.linked, + skipped: screensToMenusResult.skipped, + success: screensToMenusResult.success, + error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined, + }); + + // 2. 메뉴 → 화면관리 동기화 + const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId); + result.results.push({ + companyCode, + companyName, + direction: 'menus-to-screens', + created: menusToScreensResult.created, + linked: menusToScreensResult.linked, + skipped: menusToScreensResult.skipped, + success: menusToScreensResult.success, + error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined, + }); + + if (screensToMenusResult.success && menusToScreensResult.success) { + result.successCount++; + } else { + result.failedCount++; + } + + } catch (error: any) { + logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message }); + result.results.push({ + companyCode, + companyName, + direction: 'screens-to-menus', + created: 0, + linked: 0, + skipped: 0, + success: false, + error: error.message, + }); + result.failedCount++; + } + } + + logger.info("전체 동기화 완료", { + totalCompanies: result.totalCompanies, + successCount: result.successCount, + failedCount: result.failedCount, + }); + + return result; + + } catch (error: any) { + logger.error("전체 동기화 실패", { error: error.message }); + result.success = false; + return result; + } +} + diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 4e2878eb..cbd74337 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -111,10 +111,15 @@ export default function ScreenManagementPage() { }; // 검색어로 필터링된 화면 - const filteredScreens = screens.filter((screen) => - screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || - screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) - ); + // 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시 + // 단일 키워드면 해당 키워드로 화면 필터링 + const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean); + const filteredScreens = searchKeywords.length > 1 + ? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨) + : screens.filter((screen) => + screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || + screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) + ); // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 if (isDesignMode) { @@ -183,6 +188,7 @@ export default function ScreenManagementPage() { selectedScreen={selectedScreen} onScreenSelect={handleScreenSelect} onScreenDesign={handleDesignScreen} + searchTerm={searchTerm} onGroupSelect={(group) => { setSelectedGroup(group); setSelectedScreen(null); // 화면 선택 해제 @@ -241,5 +247,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/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index 5590cef4..a5207b96 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -927,7 +927,7 @@ export default function CopyScreenModal({ if (mode === "group") { return ( - + {/* 로딩 오버레이 */} {isCopying && (
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/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index b171bbe1..edd36816 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { ChevronRight, @@ -16,6 +16,8 @@ import { Copy, FolderTree, Loader2, + RefreshCw, + Building2, } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { @@ -24,9 +26,17 @@ import { deleteScreenGroup, addScreenToGroup, removeScreenFromGroup, + getMenuScreenSyncStatus, + syncScreenGroupsToMenu, + syncMenuToScreenGroups, + syncAllCompanies, + SyncStatus, + AllCompaniesSyncResult, } from "@/lib/api/screenGroup"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/useAuth"; +import { getCompanyList, Company } from "@/lib/api/company"; import { DropdownMenu, DropdownMenuContent, @@ -88,6 +98,7 @@ interface ScreenGroupTreeViewProps { onGroupSelect?: (group: { id: number; name: string; company_code?: string } | null) => void; onScreenSelectInGroup?: (group: { id: number; name: string; company_code?: string }, screenId: number) => void; companyCode?: string; + searchTerm?: string; // 검색어 (띄어쓰기로 구분된 여러 키워드) } interface TreeNode { @@ -107,6 +118,7 @@ export function ScreenGroupTreeView({ onGroupSelect, onScreenSelectInGroup, companyCode, + searchTerm = "", }: ScreenGroupTreeViewProps) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); @@ -155,6 +167,24 @@ export function ScreenGroupTreeView({ const [contextMenuGroup, setContextMenuGroup] = useState(null); const [contextMenuGroupPosition, setContextMenuGroupPosition] = useState<{ x: number; y: number } | null>(null); + // 메뉴-화면그룹 동기화 상태 + const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false); + const [syncStatus, setSyncStatus] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); + const [syncDirection, setSyncDirection] = useState<"screen-to-menu" | "menu-to-screen" | "all" | null>(null); + + // 회사 선택 (최고 관리자용) + const { user } = useAuth(); + const [companies, setCompanies] = useState([]); + const [selectedCompanyCode, setSelectedCompanyCode] = useState(""); + const [isSyncCompanySelectOpen, setIsSyncCompanySelectOpen] = useState(false); + + // 현재 사용자가 최고 관리자인지 확인 + const isSuperAdmin = user?.companyCode === "*"; + + // 실제 사용할 회사 코드 (props → 선택 → 사용자 기본값) + const effectiveCompanyCode = companyCode || selectedCompanyCode || (isSuperAdmin ? "" : user?.companyCode) || ""; + // 그룹 목록 및 그룹별 화면 로드 useEffect(() => { loadGroupsData(); @@ -242,6 +272,124 @@ export function ScreenGroupTreeView({ setIsGroupModalOpen(true); }; + // 동기화 다이얼로그 열기 + const handleOpenSyncDialog = async () => { + setIsSyncDialogOpen(true); + setSyncStatus(null); + setSyncDirection(null); + setSelectedCompanyCode(""); + + // 최고 관리자일 때 회사 목록 로드 + if (isSuperAdmin && companies.length === 0) { + try { + const companiesList = await getCompanyList(); + // 최고 관리자(*)용 회사는 제외 + const filteredCompanies = companiesList.filter(c => c.company_code !== "*"); + setCompanies(filteredCompanies); + } catch (error) { + console.error("회사 목록 로드 실패:", error); + } + } + + // 최고 관리자가 아니면 바로 상태 조회 + if (!isSuperAdmin && user?.companyCode) { + const response = await getMenuScreenSyncStatus(user.companyCode); + if (response.success && response.data) { + setSyncStatus(response.data); + } + } + }; + + // 회사 선택 시 상태 조회 + const handleCompanySelect = async (companyCode: string) => { + setSelectedCompanyCode(companyCode); + setIsSyncCompanySelectOpen(false); + setSyncStatus(null); + + if (companyCode) { + const response = await getMenuScreenSyncStatus(companyCode); + if (response.success && response.data) { + setSyncStatus(response.data); + } else { + toast.error(response.error || "동기화 상태 조회 실패"); + } + } + }; + + // 동기화 실행 + const handleSync = async (direction: "screen-to-menu" | "menu-to-screen") => { + // 사용할 회사 코드 결정 + const targetCompanyCode = isSuperAdmin ? selectedCompanyCode : user?.companyCode; + + if (!targetCompanyCode) { + toast.error("회사를 선택해주세요."); + return; + } + + setIsSyncing(true); + setSyncDirection(direction); + + try { + const response = direction === "screen-to-menu" + ? await syncScreenGroupsToMenu(targetCompanyCode) + : await syncMenuToScreenGroups(targetCompanyCode); + + if (response.success) { + const data = response.data; + toast.success( + `동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개` + ); + // 그룹 데이터 새로고침 + await loadGroupsData(); + // 동기화 상태 새로고침 + const statusResponse = await getMenuScreenSyncStatus(targetCompanyCode); + if (statusResponse.success && statusResponse.data) { + setSyncStatus(statusResponse.data); + } + } else { + toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`); + } + } catch (error: any) { + toast.error(`동기화 실패: ${error.message}`); + } finally { + setIsSyncing(false); + setSyncDirection(null); + } + }; + + // 전체 회사 동기화 (최고 관리자만) + const handleSyncAll = async () => { + if (!isSuperAdmin) { + toast.error("전체 동기화는 최고 관리자만 수행할 수 있습니다."); + return; + } + + setIsSyncing(true); + setSyncDirection("all"); + + try { + const response = await syncAllCompanies(); + + if (response.success && response.data) { + const data = response.data; + toast.success( + `전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개` + ); + // 그룹 데이터 새로고침 + await loadGroupsData(); + // 동기화 다이얼로그 닫기 + setIsSyncDialogOpen(false); + } else { + toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`); + } + } catch (error: any) { + toast.error(`전체 동기화 실패: ${error.message}`); + } finally { + setIsSyncing(false); + setSyncDirection(null); + } + }; + // 그룹 수정 버튼 클릭 const handleEditGroup = (group: ScreenGroup, e: React.MouseEvent) => { e.stopPropagation(); @@ -596,6 +744,191 @@ export function ScreenGroupTreeView({ return result; }; + // 검색어로 그룹 필터링 (띄어쓰기로 구분된 여러 키워드 - 계층적 검색) + const getFilteredGroups = useMemo(() => { + if (!searchTerm.trim()) { + return groups; // 검색어가 없으면 모든 그룹 반환 + } + + // 검색어를 띄어쓰기로 분리하고 빈 문자열 제거 + const keywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(k => k.length > 0); + + if (keywords.length === 0) { + return groups; + } + + // 그룹의 조상 ID들을 가져오는 함수 + const getAncestorIds = (groupId: number): Set => { + const ancestors = new Set(); + let current = groups.find(g => g.id === groupId); + while (current?.parent_group_id) { + ancestors.add(current.parent_group_id); + current = groups.find(g => g.id === current!.parent_group_id); + } + return ancestors; + }; + + // 첫 번째 키워드와 일치하는 그룹 찾기 + let currentMatchingIds = new Set(); + for (const group of groups) { + const groupName = group.group_name.toLowerCase(); + if (groupName.includes(keywords[0])) { + currentMatchingIds.add(group.id); + } + } + + // 일치하는 그룹이 없으면 빈 배열 반환 + if (currentMatchingIds.size === 0) { + return []; + } + + // 나머지 키워드들을 순차적으로 처리 (계층적 검색) + for (let i = 1; i < keywords.length; i++) { + const keyword = keywords[i]; + const nextMatchingIds = new Set(); + + for (const group of groups) { + const groupName = group.group_name.toLowerCase(); + if (groupName.includes(keyword)) { + // 이 그룹의 조상 중에 이전 키워드와 일치하는 그룹이 있는지 확인 + const ancestors = getAncestorIds(group.id); + const hasMatchingAncestor = Array.from(currentMatchingIds).some(id => + ancestors.has(id) || id === group.id + ); + + if (hasMatchingAncestor) { + nextMatchingIds.add(group.id); + } + } + } + + // 매칭되는 게 있으면 업데이트, 없으면 이전 결과 유지 + if (nextMatchingIds.size > 0) { + // 이전 키워드 매칭도 유지 (상위 폴더 표시를 위해) + nextMatchingIds.forEach(id => currentMatchingIds.add(id)); + currentMatchingIds = nextMatchingIds; + } + } + + // 최종 매칭 결과 + const finalMatchingIds = currentMatchingIds; + + // 표시할 그룹 ID 집합 + const groupsToShow = new Set(); + + // 일치하는 그룹의 상위 그룹들도 포함 (계층 유지를 위해) + const addParents = (groupId: number) => { + const group = groups.find(g => g.id === groupId); + if (group) { + groupsToShow.add(group.id); + if (group.parent_group_id) { + addParents(group.parent_group_id); + } + } + }; + + // 하위 그룹들을 추가하는 함수 + const addChildren = (groupId: number) => { + const children = groups.filter(g => g.parent_group_id === groupId); + for (const child of children) { + groupsToShow.add(child.id); + addChildren(child.id); + } + }; + + // 최종 매칭 그룹들의 상위 추가 + for (const groupId of finalMatchingIds) { + addParents(groupId); + } + + // 마지막 키워드와 일치하는 그룹의 하위만 추가 + for (const groupId of finalMatchingIds) { + addChildren(groupId); + } + + // 필터링된 그룹만 반환 + return groups.filter(g => groupsToShow.has(g.id)); + }, [groups, searchTerm]); + + // 검색 시 해당 그룹이 일치하는지 확인 (하이라이트용) + const isGroupMatchingSearch = (groupName: string): boolean => { + if (!searchTerm.trim()) return false; + const keywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(k => k.length > 0); + const name = groupName.toLowerCase(); + return keywords.some(keyword => name.includes(keyword)); + }; + + // 검색 시 해당 그룹이 자동으로 펼쳐져야 하는지 확인 + // (검색어와 일치하는 그룹의 상위 + 마지막 검색어와 일치하는 그룹도 자동 펼침) + const shouldAutoExpandForSearch = useMemo(() => { + if (!searchTerm.trim()) return new Set(); + + const keywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(k => k.length > 0); + if (keywords.length === 0) return new Set(); + + // 그룹의 조상 ID들을 가져오는 함수 + const getAncestorIds = (groupId: number): Set => { + const ancestors = new Set(); + let current = groups.find(g => g.id === groupId); + while (current?.parent_group_id) { + ancestors.add(current.parent_group_id); + current = groups.find(g => g.id === current!.parent_group_id); + } + return ancestors; + }; + + // 계층적 검색으로 최종 일치 그룹 찾기 (getFilteredGroups와 동일한 로직) + let currentMatchingIds = new Set(); + for (const group of groups) { + const groupName = group.group_name.toLowerCase(); + if (groupName.includes(keywords[0])) { + currentMatchingIds.add(group.id); + } + } + + for (let i = 1; i < keywords.length; i++) { + const keyword = keywords[i]; + const nextMatchingIds = new Set(); + + for (const group of groups) { + const groupName = group.group_name.toLowerCase(); + if (groupName.includes(keyword)) { + const ancestors = getAncestorIds(group.id); + const hasMatchingAncestor = Array.from(currentMatchingIds).some(id => + ancestors.has(id) || id === group.id + ); + + if (hasMatchingAncestor) { + nextMatchingIds.add(group.id); + } + } + } + + if (nextMatchingIds.size > 0) { + nextMatchingIds.forEach(id => currentMatchingIds.add(id)); + currentMatchingIds = nextMatchingIds; + } + } + + // 자동 펼침 대상: 일치 그룹의 상위 + 일치 그룹 자체 + const autoExpandIds = new Set(); + + const addParents = (groupId: number) => { + const group = groups.find(g => g.id === groupId); + if (group?.parent_group_id) { + autoExpandIds.add(group.parent_group_id); + addParents(group.parent_group_id); + } + }; + + for (const groupId of currentMatchingIds) { + autoExpandIds.add(groupId); // 일치하는 그룹 자체도 펼침 (화면 표시를 위해) + addParents(groupId); + } + + return autoExpandIds; + }, [groups, searchTerm]); + // 그룹 데이터 새로고침 const loadGroupsData = async () => { try { @@ -635,8 +968,8 @@ export function ScreenGroupTreeView({ return (
- {/* 그룹 추가 버튼 */} -
+ {/* 그룹 추가 & 동기화 버튼 */} +
+
{/* 트리 목록 */}
+ {/* 검색 결과 없음 표시 */} + {searchTerm.trim() && getFilteredGroups.length === 0 && ( +
+ "{searchTerm}"와 일치하는 폴더가 없습니다 +
+ )} + {/* 그룹화된 화면들 (대분류만 먼저 렌더링) */} - {groups + {getFilteredGroups .filter((g) => !(g as any).parent_group_id) // 대분류만 (parent_group_id가 null) .map((group) => { const groupId = String(group.id); - const isExpanded = expandedGroups.has(groupId); + const isExpanded = expandedGroups.has(groupId) || shouldAutoExpandForSearch.has(group.id); // 검색 시 상위 그룹만 자동 확장 const groupScreens = getScreensInGroup(group.id); + const isMatching = isGroupMatchingSearch(group.group_name); // 검색어 일치 여부 - // 하위 그룹들 찾기 - const childGroups = groups.filter((g) => (g as any).parent_group_id === group.id); + // 하위 그룹들 찾기 (필터링된 그룹에서만) + const childGroups = getFilteredGroups.filter((g) => (g as any).parent_group_id === group.id); return (
@@ -667,7 +1017,8 @@ export function ScreenGroupTreeView({
toggleGroup(groupId)} onContextMenu={(e) => handleGroupContextMenu(e, group)} @@ -682,7 +1033,7 @@ export function ScreenGroupTreeView({ ) : ( )} - {group.group_name} + {group.group_name} {groupScreens.length} @@ -719,11 +1070,12 @@ export function ScreenGroupTreeView({
{childGroups.map((childGroup) => { const childGroupId = String(childGroup.id); - const isChildExpanded = expandedGroups.has(childGroupId); + const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장 const childScreens = getScreensInGroup(childGroup.id); + const isChildMatching = isGroupMatchingSearch(childGroup.group_name); - // 손자 그룹들 (3단계) - const grandChildGroups = groups.filter((g) => (g as any).parent_group_id === childGroup.id); + // 손자 그룹들 (3단계) - 필터링된 그룹에서만 + const grandChildGroups = getFilteredGroups.filter((g) => (g as any).parent_group_id === childGroup.id); return (
@@ -731,7 +1083,8 @@ export function ScreenGroupTreeView({
toggleGroup(childGroupId)} onContextMenu={(e) => handleGroupContextMenu(e, childGroup)} @@ -746,7 +1099,7 @@ export function ScreenGroupTreeView({ ) : ( )} - {childGroup.group_name} + {childGroup.group_name} {childScreens.length} @@ -782,8 +1135,9 @@ export function ScreenGroupTreeView({
{grandChildGroups.map((grandChild) => { const grandChildId = String(grandChild.id); - const isGrandExpanded = expandedGroups.has(grandChildId); + const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장 const grandScreens = getScreensInGroup(grandChild.id); + const isGrandMatching = isGroupMatchingSearch(grandChild.group_name); return (
@@ -791,7 +1145,8 @@ export function ScreenGroupTreeView({
toggleGroup(grandChildId)} onContextMenu={(e) => handleGroupContextMenu(e, grandChild)} @@ -806,7 +1161,7 @@ export function ScreenGroupTreeView({ ) : ( )} - {grandChild.group_name} + {grandChild.group_name} {grandScreens.length} @@ -1459,6 +1814,206 @@ export function ScreenGroupTreeView({ )} + {/* 메뉴-화면그룹 동기화 다이얼로그 */} + + + + 메뉴-화면 동기화 + + 화면관리의 폴더 구조와 메뉴관리를 연동합니다. + + + + {/* 최고 관리자: 회사 선택 */} + {isSuperAdmin && ( +
+ + + + + + + + + + 회사를 찾을 수 없습니다. + + {companies.map((company) => ( + handleCompanySelect(company.company_code)} + className="text-sm" + > + + {company.company_name} + + ))} + + + + + +
+ )} + + {/* 현재 상태 표시 */} + {syncStatus ? ( +
+
+
+
화면관리
+
{syncStatus.screenGroups.total}개
+
+ 연결됨: {syncStatus.screenGroups.linked} / 미연결: {syncStatus.screenGroups.unlinked} +
+
+
+
사용자 메뉴
+
{syncStatus.menuItems.total}개
+
+ 연결됨: {syncStatus.menuItems.linked} / 미연결: {syncStatus.menuItems.unlinked} +
+
+
+ + {syncStatus.potentialMatches.length > 0 && ( +
+
자동 매칭 가능 ({syncStatus.potentialMatches.length}개)
+
+ {syncStatus.potentialMatches.slice(0, 5).map((match, i) => ( +
+ {match.menuName} = {match.groupName} +
+ ))} + {syncStatus.potentialMatches.length > 5 && ( +
...외 {syncStatus.potentialMatches.length - 5}개
+ )} +
+
+ )} + + {/* 동기화 버튼 */} +
+ + +
+ + {/* 전체 동기화 (최고 관리자만) */} + {isSuperAdmin && ( +
+ +
+ )} +
+ ) : isSuperAdmin && !selectedCompanyCode ? ( +
+ +

+ 개별 회사 동기화를 하려면 회사를 선택해주세요. +

+ + {/* 전체 회사 동기화 버튼 (회사 선택 없이도 표시) */} +
+ +
+
+ ) : ( +
+ +
+ )} + + + + +
+
+
); } \ No newline at end of file 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/components/screen/panels/DataFlowPanel.tsx b/frontend/components/screen/panels/DataFlowPanel.tsx index 4b31d1a9..1fdf0ec8 100644 --- a/frontend/components/screen/panels/DataFlowPanel.tsx +++ b/frontend/components/screen/panels/DataFlowPanel.tsx @@ -462,3 +462,4 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF + diff --git a/frontend/components/screen/panels/FieldJoinPanel.tsx b/frontend/components/screen/panels/FieldJoinPanel.tsx index 884ac69b..29891228 100644 --- a/frontend/components/screen/panels/FieldJoinPanel.tsx +++ b/frontend/components/screen/panels/FieldJoinPanel.tsx @@ -414,3 +414,4 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel + 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/screenGroup.ts b/frontend/lib/api/screenGroup.ts index 65294444..0a91f907 100644 --- a/frontend/lib/api/screenGroup.ts +++ b/frontend/lib/api/screenGroup.ts @@ -498,3 +498,97 @@ export async function getScreenSubTables( } } + +// ============================================================ +// 메뉴-화면그룹 동기화 API +// ============================================================ + +export interface SyncDetail { + action: 'created' | 'linked' | 'skipped' | 'error'; + sourceName: string; + sourceId: number | string; + targetId?: number | string; + reason?: string; +} + +export interface SyncResult { + success: boolean; + created: number; + linked: number; + skipped: number; + errors: string[]; + details: SyncDetail[]; +} + +export interface SyncStatus { + screenGroups: { total: number; linked: number; unlinked: number }; + menuItems: { total: number; linked: number; unlinked: number }; + potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>; +} + +// 동기화 상태 조회 +export async function getMenuScreenSyncStatus( + targetCompanyCode?: string +): Promise> { + try { + const queryParams = targetCompanyCode ? `?targetCompanyCode=${targetCompanyCode}` : ''; + const response = await apiClient.get(`/screen-groups/sync/status${queryParams}`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// 화면관리 → 메뉴 동기화 +export async function syncScreenGroupsToMenu( + targetCompanyCode?: string +): Promise> { + try { + const response = await apiClient.post("/screen-groups/sync/screen-to-menu", { targetCompanyCode }); + return response.data; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// 메뉴 → 화면관리 동기화 +export async function syncMenuToScreenGroups( + targetCompanyCode?: string +): Promise> { + try { + const response = await apiClient.post("/screen-groups/sync/menu-to-screen", { targetCompanyCode }); + return response.data; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// 전체 동기화 결과 타입 +export interface AllCompaniesSyncResult { + totalCompanies: number; + successCount: number; + failedCount: number; + totalCreated: number; + totalLinked: number; + details: Array<{ + companyCode: string; + companyName: string; + direction: 'screens-to-menus' | 'menus-to-screens'; + created: number; + linked: number; + skipped: number; + success: boolean; + error?: string; + }>; +} + +// 전체 회사 동기화 (최고 관리자만) +export async function syncAllCompanies(): Promise> { + try { + const response = await apiClient.post("/screen-groups/sync/all"); + return response.data; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + 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/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index 4f4595ff..bdc00019 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -184,7 +184,7 @@ const DataCell: React.FC = ({ onClick={onClick} onDoubleClick={onDoubleClick} > - - + 0 ); } @@ -222,7 +222,7 @@ const DataCell: React.FC = ({ )} {icon && {icon}} - {values[0].formattedValue} + {values[0].formattedValue || (values[0].value === 0 ? '0' : values[0].formattedValue)} ); @@ -257,7 +257,7 @@ const DataCell: React.FC = ({ )} {icon && {icon}} - {val.formattedValue} + {val.formattedValue || (val.value === 0 ? '0' : val.formattedValue)} ))} @@ -303,6 +303,17 @@ export const PivotGridComponent: React.FC = ({ externalDataLength: externalData?.length, initialFieldsLength: initialFields?.length, }); + + // 🆕 데이터 샘플 확인 + if (externalData && externalData.length > 0) { + console.log("🔶 첫 번째 데이터 샘플:", externalData[0]); + console.log("🔶 전체 데이터 개수:", externalData.length); + } + + // 🆕 필드 설정 확인 + if (initialFields && initialFields.length > 0) { + console.log("🔶 필드 설정:", initialFields); + } // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); @@ -312,6 +323,9 @@ export const PivotGridComponent: React.FC = ({ sortConfig: null, filterConfig: {}, }); + + // 🆕 초기 로드 시 자동 확장 (첫 레벨만) + const [isInitialExpanded, setIsInitialExpanded] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태 const [showFieldChooser, setShowFieldChooser] = useState(false); @@ -494,13 +508,52 @@ export const PivotGridComponent: React.FC = ({ return null; } - return processPivotData( + const result = processPivotData( filteredData, visibleFields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths ); + + // 🆕 피벗 결과 확인 + console.log("🔶 피벗 처리 결과:", { + hasResult: !!result, + flatRowsCount: result?.flatRows?.length, + flatColumnsCount: result?.flatColumns?.length, + dataMatrixSize: result?.dataMatrix?.size, + expandedRowPaths: pivotState.expandedRowPaths.length, + expandedColumnPaths: pivotState.expandedColumnPaths.length, + }); + + return result; }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); + + // 🆕 초기 로드 시 첫 레벨 자동 확장 + useEffect(() => { + if (pivotResult && pivotResult.flatRows.length > 0) { + console.log("🔶 피벗 결과 생성됨:", { + flatRowsCount: pivotResult.flatRows.length, + expandedRowPaths: pivotState.expandedRowPaths.length, + isInitialExpanded, + }); + + // 첫 레벨 행들의 경로 수집 (level 0인 행들) + const firstLevelRows = pivotResult.flatRows.filter(row => row.level === 0 && row.hasChildren); + + console.log("🔶 첫 레벨 행 (level 0, hasChildren):", firstLevelRows.map(r => ({ path: r.path, caption: r.caption }))); + + // 초기 확장이 안 되어 있고, 첫 레벨 행이 있으면 자동 확장 + if (!isInitialExpanded && firstLevelRows.length > 0) { + const firstLevelPaths = firstLevelRows.map(row => row.path); + console.log("🔶 초기 자동 확장 실행:", firstLevelPaths); + setPivotState(prev => ({ + ...prev, + expandedRowPaths: firstLevelPaths, + })); + setIsInitialExpanded(true); + } + } + }, [pivotResult, isInitialExpanded, pivotState.expandedRowPaths.length]); // 조건부 서식용 전체 값 수집 const allCellValues = useMemo(() => { @@ -665,6 +718,8 @@ export const PivotGridComponent: React.FC = ({ // 행 확장/축소 const handleToggleRowExpand = useCallback( (path: string[]) => { + console.log("🔶 행 확장/축소 클릭:", path); + setPivotState((prev) => { const pathKey = pathToKey(path); const existingIndex = prev.expandedRowPaths.findIndex( @@ -673,13 +728,16 @@ export const PivotGridComponent: React.FC = ({ let newPaths: string[][]; if (existingIndex >= 0) { + console.log("🔶 행 축소:", path); newPaths = prev.expandedRowPaths.filter( (_, i) => i !== existingIndex ); } else { + console.log("🔶 행 확장:", path); newPaths = [...prev.expandedRowPaths, path]; } + console.log("🔶 새로운 확장 경로:", newPaths); onExpandChange?.(newPaths); return { @@ -1557,13 +1615,13 @@ export const PivotGridComponent: React.FC = ({ {/* 열 헤더 */} - + {/* 좌상단 코너 (행 필드 라벨 + 필터) */} ))} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} - {/* 열 필드 필터 (헤더 왼쪽에 표시) */} + {/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */} {columnFields.length > 0 && ( )} - - {/* 행 총계 헤더 */} - {totals?.showRowGrandTotals && ( - - )} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {dataFields.length > 1 && ( - + {flatColumns.map((col, colIdx) => ( {dataFields.map((df, dfIdx) => ( @@ -1697,7 +1756,7 @@ export const PivotGridComponent: React.FC = ({ key={`${colIdx}-${dfIdx}`} className={cn( "border-r border-b border-border", - "px-2 py-1 text-center text-xs font-normal", + "px-2 py-0.5 text-center text-xs font-normal", "text-muted-foreground cursor-pointer hover:bg-accent/50" )} onClick={() => handleSort(df.field)} @@ -1710,19 +1769,6 @@ export const PivotGridComponent: React.FC = ({ ))} ))} - {totals?.showRowGrandTotals && - dataFields.map((df, dfIdx) => ( - - ))} )} diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx index 8e3563d9..191f3610 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -1,12 +1,13 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; import { PivotFieldConfig } from "./types"; +import { dataApi } from "@/lib/api/data"; // ==================== 샘플 데이터 (미리보기용) ==================== @@ -95,6 +96,48 @@ const PivotGridWrapper: React.FC = (props) => { const configFields = componentConfig.fields || props.fields; const configData = props.data; + // 🆕 테이블에서 데이터 자동 로딩 + const [loadedData, setLoadedData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const loadTableData = async () => { + const tableName = componentConfig.dataSource?.tableName; + + // 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음 + if (configData || !tableName || props.isDesignMode) { + return; + } + + setIsLoading(true); + try { + console.log("🔷 [PivotGrid] 테이블 데이터 로딩 시작:", tableName); + + const response = await dataApi.getTableData(tableName, { + page: 1, + size: 10000, // 피벗 분석용 대량 데이터 (pageSize → size) + }); + + console.log("🔷 [PivotGrid] API 응답:", response); + + // dataApi.getTableData는 { data, total, page, size, totalPages } 구조 + if (response.data && Array.isArray(response.data)) { + setLoadedData(response.data); + console.log("✅ [PivotGrid] 데이터 로딩 완료:", response.data.length, "건"); + } else { + console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음"); + setLoadedData([]); + } + } catch (error) { + console.error("❌ [PivotGrid] 데이터 로딩 에러:", error); + } finally { + setIsLoading(false); + } + }; + + loadTableData(); + }, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]); + // 디버깅 로그 console.log("🔷 PivotGridWrapper props:", { isDesignMode: props.isDesignMode, @@ -103,23 +146,28 @@ const PivotGridWrapper: React.FC = (props) => { hasConfig: !!props.config, hasData: !!configData, dataLength: configData?.length, + hasLoadedData: loadedData.length > 0, + loadedDataLength: loadedData.length, hasFields: !!configFields, fieldsLength: configFields?.length, + isLoading, }); // 디자인 모드 판단: // 1. isDesignMode === true // 2. isInteractive === false (편집 모드) - // 3. 데이터가 없는 경우 const isDesignMode = props.isDesignMode === true || props.isInteractive === false; - const hasValidData = configData && Array.isArray(configData) && configData.length > 0; + + // 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터 + const actualData = configData || loadedData; + const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0; const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 - const usePreviewData = isDesignMode || !hasValidData; + const usePreviewData = isDesignMode || (!hasValidData && !isLoading); // 최종 데이터/필드 결정 - const finalData = usePreviewData ? SAMPLE_DATA : configData; + const finalData = usePreviewData ? SAMPLE_DATA : actualData; const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; const finalTitle = usePreviewData ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" @@ -140,6 +188,18 @@ const PivotGridWrapper: React.FC = (props) => { showColumnTotals: true, }; + // 🆕 로딩 중 표시 + if (isLoading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + return ( = (props) => { fieldChooser={componentConfig.fieldChooser || props.fieldChooser} chart={componentConfig.chart || props.chart} allowExpandAll={componentConfig.allowExpandAll !== false} - height={componentConfig.height || props.height || "400px"} + height="100%" maxHeight={componentConfig.maxHeight || props.maxHeight} exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }} onCellClick={props.onCellClick} @@ -279,7 +339,7 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { fieldChooser={componentConfig.fieldChooser || props.fieldChooser} chart={componentConfig.chart || props.chart} allowExpandAll={componentConfig.allowExpandAll !== false} - height={componentConfig.height || props.height || "400px"} + height="100%" maxHeight={componentConfig.maxHeight || props.maxHeight} exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }} onCellClick={props.onCellClick} diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx index de4a8948..89fe5128 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -401,7 +401,7 @@ export const FieldChooser: React.FC = ({ {/* 필드 목록 */} - +
{filteredFields.length === 0 ? (
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 9609f4aa..50f7c41b 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; @@ -148,13 +144,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; @@ -182,11 +171,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>({}); // 좌측 컬럼 라벨 @@ -197,10 +181,6 @@ export const SplitPanelLayoutComponent: React.FC const [rightCategoryMappings, setRightCategoryMappings] = useState< Record> >({}); // 우측 카테고리 매핑 - - // 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨) - const [categoryCodeLabels, setCategoryCodeLabels] = useState>({}); - const { toast } = useToast(); // 추가 모달 상태 @@ -234,12 +214,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); @@ -264,15 +244,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); }; // 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리 @@ -340,11 +320,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; } @@ -632,41 +612,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( ( @@ -729,14 +674,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"); @@ -758,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC // 일반 값 return String(value); }, - [formatDateValue, formatNumberValue, categoryCodeLabels], + [formatDateValue, formatNumberValue], ); // 좌측 데이터 로드 @@ -828,8 +765,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, @@ -842,10 +779,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; @@ -904,8 +841,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") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -975,68 +911,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, // 🆕 프리뷰용 회사 코드 오버라이드 }); @@ -1045,14 +939,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": @@ -1061,12 +951,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; } @@ -1076,7 +960,7 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우 + // 단일키 (하위 호환성) const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; @@ -1094,9 +978,6 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) - } else { - console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName); - setRightData([]); } } } @@ -1120,294 +1001,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], ); // 우측 항목 확장/축소 토글 @@ -1601,7 +1211,7 @@ export const SplitPanelLayoutComponent: React.FC } }); setLeftColumnLabels(labels); - // console.log("✅ 좌측 컬럼 라벨 로드:", labels); + console.log("✅ 좌측 컬럼 라벨 로드:", labels); } catch (error) { console.error("좌측 테이블 컬럼 라벨 로드 실패:", error); } @@ -1630,7 +1240,7 @@ export const SplitPanelLayoutComponent: React.FC } }); setRightColumnLabels(labels); - // console.log("✅ 우측 컬럼 라벨 로드:", labels); + console.log("✅ 우측 컬럼 라벨 로드:", labels); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } @@ -1672,7 +1282,7 @@ export const SplitPanelLayoutComponent: React.FC }; }); mappings[columnName] = valueMap; - // console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap); + console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap); } } catch (error) { console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error); @@ -1710,7 +1320,7 @@ export const SplitPanelLayoutComponent: React.FC } }); - // console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); + console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); // 각 테이블에 대해 카테고리 매핑 로드 for (const tableName of tablesToLoad) { @@ -1803,22 +1413,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, }); @@ -1829,99 +1453,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), }), @@ -1930,10 +1478,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) : "없음", }); @@ -1947,7 +1495,7 @@ export const SplitPanelLayoutComponent: React.FC setEditModalFormData({ ...item }); setShowEditModal(true); }, - [componentConfig, activeTabIndex], + [componentConfig], ); // 수정 모달 저장 @@ -2047,18 +1595,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); } @@ -2083,89 +1626,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); } @@ -2188,12 +1689,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({ @@ -2217,17 +1713,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( @@ -2467,7 +1953,7 @@ export const SplitPanelLayoutComponent: React.FC useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { - // console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); + console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); // 선택된 항목이 있으면 우측 패널도 새로고침 if (selectedLeftItem) { @@ -2569,7 +2055,7 @@ export const SplitPanelLayoutComponent: React.FC >
- {getTranslatedText(componentConfig.leftPanel?.langKey, componentConfig.leftPanel?.title || "좌측 패널")} + {componentConfig.leftPanel?.title || "좌측 패널"} {!isDesignMode && componentConfig.leftPanel?.showAdd && (
0 ? 2 : 1} > @@ -1607,8 +1665,8 @@ export const PivotGridComponent: React.FC = ({ key={idx} className={cn( "border-r border-b border-border relative group", - "px-2 py-1.5 text-center text-xs font-medium", - "bg-muted/70 sticky top-0 z-10", + "px-2 py-1 text-center text-xs font-medium", + "bg-background sticky top-0 z-10", dataFields.length === 1 && "cursor-pointer hover:bg-accent/50" )} colSpan={dataFields.length || 1} @@ -1630,16 +1688,31 @@ export const PivotGridComponent: React.FC = ({ /> 1 ? 2 : 1} + > + 총계 + 0 ? 2 : 1} + rowSpan={dataFields.length > 1 ? 2 : 1} >
{columnFields.map((f) => ( @@ -1671,25 +1744,11 @@ export const PivotGridComponent: React.FC = ({
- 총계 -
- {df.caption} -
- - - + + + @@ -2631,12 +2117,12 @@ export const SplitPanelLayoutComponent: React.FC (() => { // 🆕 그룹별 합산된 데이터 사용 const dataSource = summedLeftData; - // console.log( - // "🔍 [테이블모드 렌더링] dataSource 개수:", - // dataSource.length, - // "leftGroupSumConfig:", - // leftGroupSumConfig, - // ); + console.log( + "🔍 [테이블모드 렌더링] dataSource 개수:", + dataSource.length, + "leftGroupSumConfig:", + leftGroupSumConfig, + ); // 🔧 로컬 검색 필터 적용 const filteredData = leftSearchQuery @@ -2654,12 +2140,10 @@ export const SplitPanelLayoutComponent: React.FC visibleLeftColumns.length > 0 ? visibleLeftColumns.map((col: any) => { const colName = typeof col === "string" ? col : col.name || col.columnName; - const originalLabel = - leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName; - const colLangKey = typeof col === "object" ? col.langKey : undefined; return { name: colName, - label: colLangKey ? getTranslatedText(colLangKey, originalLabel) : originalLabel, + label: + leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName, width: typeof col === "object" ? col.width : 150, align: (typeof col === "object" ? col.align : "left") as "left" | "center" | "right", format: typeof col === "object" ? col.format : undefined, // 🆕 포맷 설정 포함 @@ -2691,7 +2175,7 @@ export const SplitPanelLayoutComponent: React.FC {columnsToShow.map((col, idx) => ( ); } @@ -5887,18 +5644,13 @@ export const TableListComponent: React.FC = ({ {visibleColumns.map((column, columnIndex) => { const columnWidth = columnWidths[column.columnName]; const isFrozen = frozenColumns.includes(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산) - // 숨겨진 컬럼은 제외하고 보이는 틀고정 컬럼만 포함 - const visibleFrozenColumns = visibleColumns - .filter(col => frozenColumns.includes(col.columnName)) - .map(col => col.columnName); - const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); - + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = visibleFrozenColumns[i]; + const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; @@ -5964,12 +5716,7 @@ export const TableListComponent: React.FC = ({ )} - - {/* langKey가 있으면 다국어 번역 사용 */} - {(column as any).langKey - ? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.displayName || column.columnName) - : columnLabels[column.columnName] || column.displayName} - + {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} )} @@ -6017,16 +5764,16 @@ export const TableListComponent: React.FC = ({ )}
- {columnUniqueValues[column.columnName]?.slice(0, 50).map((item) => { - const isSelected = headerFilters[column.columnName]?.has(item.value); + {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { + const isSelected = headerFilters[column.columnName]?.has(val); return (
toggleHeaderFilter(column.columnName, item.value)} + onClick={() => toggleHeaderFilter(column.columnName, val)} >
= ({ > {isSelected && }
- {item.label || "(빈 값)"} + {val || "(빈 값)"}
); })} @@ -6209,17 +5956,13 @@ export const TableListComponent: React.FC = ({ const isNumeric = inputType === "number" || inputType === "decimal"; const isFrozen = frozenColumns.includes(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산) - const visibleFrozenColumns = visibleColumns - .filter(col => frozenColumns.includes(col.columnName)) - .map(col => col.columnName); - const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = visibleFrozenColumns[i]; + const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; @@ -6366,12 +6109,7 @@ export const TableListComponent: React.FC = ({ const isNumeric = inputType === "number" || inputType === "decimal"; const isFrozen = frozenColumns.includes(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산) - const visibleFrozenColumns = visibleColumns - .filter(col => frozenColumns.includes(col.columnName)) - .map(col => col.columnName); - const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); // 셀 포커스 상태 const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; @@ -6385,10 +6123,11 @@ export const TableListComponent: React.FC = ({ // 🆕 검색 하이라이트 여부 const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); + // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = visibleFrozenColumns[i]; + const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; @@ -6548,17 +6287,13 @@ export const TableListComponent: React.FC = ({ const summary = summaryData[column.columnName]; const columnWidth = columnWidths[column.columnName]; const isFrozen = frozenColumns.includes(column.columnName); - - // 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산) - const visibleFrozenColumns = visibleColumns - .filter(col => frozenColumns.includes(col.columnName)) - .map(col => col.columnName); - const frozenIndex = visibleFrozenColumns.indexOf(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = visibleFrozenColumns[i]; + const frozenCol = frozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth;
컬럼 1컬럼 2컬럼 3컬럼 1컬럼 2컬럼 3
{columnsToShow.map((col, idx) => ( className="flex flex-shrink-0 flex-col" > - {/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */} - {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && ( -
- handleTabChange(Number(value))} - className="w-full" - > - - - {componentConfig.rightPanel?.title || "기본"} - - {componentConfig.rightPanel?.additionalTabs?.map((tab, index) => ( - - {tab.label || `탭 ${index + 1}`} - - ))} - - -
- )} >
- {activeTabIndex === 0 - ? getTranslatedText( - componentConfig.rightPanel?.langKey, - componentConfig.rightPanel?.title || "우측 패널", - ) - : getTranslatedText( - componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.titleLangKey, - componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title || - componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label || - "우측 패널", - )} + {componentConfig.rightPanel?.title || "우측 패널"} {!isDesignMode && (
- {/* 현재 활성 탭에 따른 추가 버튼 */} - {activeTabIndex === 0 - ? componentConfig.rightPanel?.showAdd && ( - - ) - : componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && ( - - )} + {componentConfig.rightPanel?.showAdd && ( + + )} {/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
)} @@ -3137,203 +2575,347 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 🆕 추가 탭 데이터 렌더링 */} - {activeTabIndex > 0 ? ( - // 추가 탭 컨텐츠 - (() => { - const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; - const currentTabData = tabsData[activeTabIndex] || []; - const isTabLoading = tabsLoading[activeTabIndex]; + {/* 우측 데이터 */} + {isLoadingRight ? ( + // 로딩 중 +
+
+ +

데이터를 불러오는 중...

+
+
+ ) : rightData ? ( + // 실제 데이터 표시 + Array.isArray(rightData) ? ( + // 조인 모드: 여러 데이터를 테이블/리스트로 표시 + (() => { + // 검색 필터링 + const filteredData = rightSearchQuery + ? rightData.filter((item) => { + const searchLower = rightSearchQuery.toLowerCase(); + return Object.entries(item).some(([key, value]) => { + if (value === null || value === undefined) return false; + return String(value).toLowerCase().includes(searchLower); + }); + }) + : rightData; - if (isTabLoading) { - return ( -
-
- -

데이터를 불러오는 중...

+ // 테이블 모드 체크 + const isTableMode = componentConfig.rightPanel?.displayMode === "table"; + + if (isTableMode) { + // 테이블 모드 렌더링 + const displayColumns = componentConfig.rightPanel?.columns || []; + + // 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시 + const relationKeys = componentConfig.rightPanel?.relation?.keys || []; + const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean); + const isGroupedMode = selectedLeftItem?._originalItems?.length > 0; + + let columnsToShow: any[] = []; + + if (displayColumns.length > 0) { + // 설정된 컬럼 사용 + columnsToShow = displayColumns.map((col) => ({ + ...col, + label: rightColumnLabels[col.name] || col.label || col.name, + format: col.format, + })); + + // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 + if (isGroupedMode && keyColumns.length > 0) { + const existingColNames = columnsToShow.map((c) => c.name); + const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k)); + + if (missingKeyColumns.length > 0) { + const keyColsToAdd = missingKeyColumns.map((colName: string) => ({ + name: colName, + label: rightColumnLabels[colName] || colName, + width: 120, + align: "left" as const, + format: undefined, + _isKeyColumn: true, // 구분용 플래그 + })); + columnsToShow = [...keyColsToAdd, ...columnsToShow]; + console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns); + } + } + } else { + // 기본 컬럼 자동 생성 + columnsToShow = Object.keys(filteredData[0] || {}) + .filter((key) => shouldShowField(key)) + .slice(0, 5) + .map((key) => ({ + name: key, + label: rightColumnLabels[key] || key, + width: 150, + align: "left" as const, + format: undefined, + })); + } + + return ( +
+
+ {filteredData.length}개의 관련 데이터 + {rightSearchQuery && filteredData.length !== rightData.length && ( + (전체 {rightData.length}개 중) + )} +
+
+ + + + {columnsToShow.map((col, idx) => ( + + ))} + {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} + {!isDesignMode && + ((componentConfig.rightPanel?.editButton?.enabled ?? true) || + (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( + + )} + + + + {filteredData.map((item, idx) => { + const itemId = item.id || item.ID || idx; + + return ( + + {columnsToShow.map((col, colIdx) => ( + + ))} + {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} + {!isDesignMode && + ((componentConfig.rightPanel?.editButton?.enabled ?? true) || + (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( + + )} + + ); + })} + +
+ {col.label} + + 작업 +
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} + +
+ {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + + )} + {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( + + )} +
+
+
-
- ); - } + ); + } - if (!selectedLeftItem) { - return ( -
-

좌측에서 항목을 선택하세요

-
- ); - } - - if (currentTabData.length === 0) { - return ( -
-

데이터가 없습니다

-
- ); - } - - // 탭 데이터 렌더링 (목록/테이블 모드) - const isTableMode = currentTabConfig?.displayMode === "table"; - - if (isTableMode) { - // 테이블 모드 - const displayColumns = currentTabConfig?.columns || []; - const columnsToShow = - displayColumns.length > 0 - ? displayColumns.map((col) => ({ - ...col, - label: col.label || col.name, - })) - : Object.keys(currentTabData[0] || {}) - .filter(shouldShowField) - .slice(0, 8) - .map((key) => ({ name: key, label: key })); - - return ( -
- - - - {columnsToShow.map((col: any) => ( - - ))} - {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( - - )} - - - - {currentTabData.map((item: any, idx: number) => ( - - {columnsToShow.map((col: any) => ( - - ))} - {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( - - )} - - ))} - -
- {col.label} - 작업
- {formatValue(item[col.name], col.format)} - -
- {currentTabConfig?.showEdit && ( - - )} - {currentTabConfig?.showDelete && ( - - )} -
-
-
- ); - } else { - // 목록 (카드) 모드 - const displayColumns = currentTabConfig?.columns || []; - const summaryCount = currentTabConfig?.summaryColumnCount ?? 3; - const showLabel = currentTabConfig?.summaryShowLabel ?? true; - - return ( + // 목록 모드 (기존) + return filteredData.length > 0 ? (
- {currentTabData.map((item: any, idx: number) => { - const itemId = item.id || idx; +
+ {filteredData.length}개의 관련 데이터 + {rightSearchQuery && filteredData.length !== rightData.length && ( + (전체 {rightData.length}개 중) + )} +
+ {filteredData.map((item, index) => { + const itemId = item.id || item.ID || index; const isExpanded = expandedRightItems.has(itemId); - // 표시할 컬럼 결정 - const columnsToShow = - displayColumns.length > 0 - ? displayColumns - : Object.keys(item) - .filter(shouldShowField) - .slice(0, 8) - .map((key) => ({ name: key, label: key })); + // 우측 패널 표시 컬럼 설정 확인 + const rightColumns = componentConfig.rightPanel?.columns; + let firstValues: [string, any, string][] = []; + let allValues: [string, any, string][] = []; - const summaryColumns = columnsToShow.slice(0, summaryCount); - const detailColumns = columnsToShow.slice(summaryCount); + if (rightColumns && rightColumns.length > 0) { + // 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) + const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; + firstValues = rightColumns + .slice(0, summaryCount) + .map((col) => { + // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) + const value = getEntityJoinValue(item, col.name); + return [col.name, value, col.label] as [string, any, string]; + }) + .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + + allValues = rightColumns + .map((col) => { + // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) + const value = getEntityJoinValue(item, col.name); + return [col.name, value, col.label] as [string, any, string]; + }) + .filter(([_, value]) => value !== null && value !== undefined && value !== ""); + } else { + // 설정 없으면 모든 컬럼 표시 (기존 로직) + const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; + firstValues = Object.entries(item) + .filter(([key]) => !key.toLowerCase().includes("id")) + .slice(0, summaryCount) + .map(([key, value]) => [key, value, ""] as [string, any, string]); + + allValues = Object.entries(item) + .filter(([key, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => [key, value, ""] as [string, any, string]); + } return ( -
-
toggleRightItemExpansion(itemId)} - > -
-
- {summaryColumns.map((col: any) => ( -
- {showLabel && {col.label}:} - - {formatValue(item[col.name], col.format)} - -
- ))} +
+ {/* 요약 정보 */} +
+
+
toggleRightItemExpansion(itemId)} + > +
+ {firstValues.map(([key, value, label], idx) => { + // 포맷 설정 및 볼드 설정 찾기 + const colConfig = rightColumns?.find((c) => c.name === key); + const format = colConfig?.format; + const boldValue = colConfig?.bold ?? false; + + // 🆕 포맷 적용 (날짜/숫자/카테고리) + const displayValue = formatCellValue(key, value, rightCategoryMappings, format); + + const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true; + + return ( +
+ {showLabel && ( + + {label || getColumnLabel(key)}: + + )} + + {displayValue} + +
+ ); + })} +
+
+
+ {/* 수정 버튼 */} + {!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + + )} + {/* 삭제 버튼 */} + {!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( + + )} + {/* 확장/접기 버튼 */} +
-
- {currentTabConfig?.showEdit && ( - - )} - {currentTabConfig?.showDelete && ( - - )} - {detailColumns.length > 0 && - (isExpanded ? ( - - ) : ( - - ))} -
- {isExpanded && detailColumns.length > 0 && ( -
-
- {detailColumns.map((col: any) => ( -
- {col.label}: - {formatValue(item[col.name], col.format)} -
- ))} + + {/* 상세 정보 (확장 시 표시) */} + {isExpanded && ( +
+
전체 상세 정보
+
+ + + {allValues.map(([key, value, label]) => { + // 포맷 설정 찾기 + const colConfig = rightColumns?.find((c) => c.name === key); + const format = colConfig?.format; + + // 🆕 포맷 적용 (날짜/숫자/카테고리) + const displayValue = formatCellValue(key, value, rightCategoryMappings, format); + + return ( + + + + + ); + })} + +
+ {label || getColumnLabel(key)} + {displayValue}
)} @@ -3341,478 +2923,109 @@ export const SplitPanelLayoutComponent: React.FC ); })}
- ); - } - })() - ) : ( - /* 기본 탭 (우측 패널) 데이터 */ - <> - {isLoadingRight ? ( - // 로딩 중 -
-
- -

데이터를 불러오는 중...

+ ) : ( +
+ {rightSearchQuery ? ( + <> +

검색 결과가 없습니다.

+

다른 검색어를 입력해보세요.

+ + ) : ( + "관련 데이터가 없습니다." + )}
-
- ) : rightData ? ( - // 실제 데이터 표시 - Array.isArray(rightData) ? ( - // 조인 모드: 여러 데이터를 테이블/리스트로 표시 - (() => { - // 검색 필터링 - const filteredData = rightSearchQuery - ? rightData.filter((item) => { - const searchLower = rightSearchQuery.toLowerCase(); - return Object.entries(item).some(([key, value]) => { - if (value === null || value === undefined) return false; - return String(value).toLowerCase().includes(searchLower); - }); - }) - : rightData; + ); + })() + ) : ( + // 상세 모드: 단일 객체를 상세 정보로 표시 + (() => { + const rightColumns = componentConfig.rightPanel?.columns; + let displayEntries: [string, any, string][] = []; - // 테이블 모드 체크 - const isTableMode = componentConfig.rightPanel?.displayMode === "table"; + if (rightColumns && rightColumns.length > 0) { + console.log("🔍 [디버깅] 상세 모드 표시 로직:"); + console.log(" 📋 rightData 전체:", rightData); + console.log(" 📋 rightData keys:", Object.keys(rightData)); + console.log( + " ⚙️ 설정된 컬럼:", + rightColumns.map((c) => `${c.name} (${c.label})`), + ); - if (isTableMode) { - // 테이블 모드 렌더링 - const displayColumns = componentConfig.rightPanel?.columns || []; + // 설정된 컬럼만 표시 + displayEntries = rightColumns + .map((col) => { + // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) + let value = rightData[col.name]; + console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); - // 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시 - const relationKeys = componentConfig.rightPanel?.relation?.keys || []; - const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean); - const isGroupedMode = selectedLeftItem?._originalItems?.length > 0; - - let columnsToShow: any[] = []; - - if (displayColumns.length > 0) { - // 설정된 컬럼 사용 - 🌐 다국어 처리 추가 - columnsToShow = displayColumns.map((col) => { - const originalLabel = rightColumnLabels[col.name] || col.label || col.name; - const colLangKey = (col as any).langKey; - return { - ...col, - label: colLangKey ? getTranslatedText(colLangKey, originalLabel) : originalLabel, - format: col.format, - }; - }); - - // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 - if (isGroupedMode && keyColumns.length > 0) { - const existingColNames = columnsToShow.map((c) => c.name); - const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k)); - - if (missingKeyColumns.length > 0) { - const keyColsToAdd = missingKeyColumns.map((colName: string) => ({ - name: colName, - label: rightColumnLabels[colName] || colName, - width: 120, - align: "left" as const, - format: undefined, - _isKeyColumn: true, // 구분용 플래그 - })); - columnsToShow = [...keyColsToAdd, ...columnsToShow]; - console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns); - } - } - } else { - // 기본 컬럼 자동 생성 - columnsToShow = Object.keys(filteredData[0] || {}) - .filter((key) => shouldShowField(key)) - .slice(0, 5) - .map((key) => ({ - name: key, - label: rightColumnLabels[key] || key, - width: 150, - align: "left" as const, - format: undefined, - })); + if (value === undefined && col.name.includes(".")) { + const columnName = col.name.split(".").pop(); + value = rightData[columnName || ""]; + console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); } - return ( -
-
- {filteredData.length}개의 관련 데이터 - {rightSearchQuery && filteredData.length !== rightData.length && ( - (전체 {rightData.length}개 중) - )} -
-
- - - - {columnsToShow.map((col, idx) => ( - - ))} - {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} - {!isDesignMode && - ((componentConfig.rightPanel?.editButton?.enabled ?? true) || - (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - - )} - - - - {filteredData.map((item, idx) => { - const itemId = item.id || item.ID || idx; + return [col.name, value, col.label] as [string, any, string]; + }) + .filter(([key, value]) => { + const filtered = value === null || value === undefined || value === ""; + if (filtered) { + console.log(` ❌ 필터링됨: "${key}" (값: ${value})`); + } + return !filtered; + }); - return ( - - {columnsToShow.map((col, colIdx) => ( - - ))} - {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} - {!isDesignMode && - ((componentConfig.rightPanel?.editButton?.enabled ?? true) || - (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - - )} - - ); - })} - -
- {col.label} - 작업
- {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, - )} - -
- {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( - - )} - {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( - - )} -
-
-
+ console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); + } else { + // 설정 없으면 모든 컬럼 표시 + displayEntries = Object.entries(rightData) + .filter(([_, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => [key, value, ""] as [string, any, string]); + console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); + } + + return ( +
+ {displayEntries.map(([key, value, label]) => ( +
+
+ {label || getColumnLabel(key)}
- ); - } - - // 목록 모드 (기존) - return filteredData.length > 0 ? ( -
-
- {filteredData.length}개의 관련 데이터 - {rightSearchQuery && filteredData.length !== rightData.length && ( - (전체 {rightData.length}개 중) - )} -
- {filteredData.map((item, index) => { - const itemId = item.id || item.ID || index; - const isExpanded = expandedRightItems.has(itemId); - - // 우측 패널 표시 컬럼 설정 확인 - const rightColumns = componentConfig.rightPanel?.columns; - let firstValues: [string, any, string][] = []; - let allValues: [string, any, string][] = []; - - if (rightColumns && rightColumns.length > 0) { - // 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) - const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; - firstValues = rightColumns - .slice(0, summaryCount) - .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) - const value = getEntityJoinValue(item, col.name); - return [col.name, value, col.label] as [string, any, string]; - }) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); - - allValues = rightColumns - .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용) - const value = getEntityJoinValue(item, col.name); - return [col.name, value, col.label] as [string, any, string]; - }) - .filter(([_, value]) => value !== null && value !== undefined && value !== ""); - } else { - // 설정 없으면 모든 컬럼 표시 (기존 로직) - const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; - firstValues = Object.entries(item) - .filter(([key]) => !key.toLowerCase().includes("id")) - .slice(0, summaryCount) - .map(([key, value]) => [key, value, ""] as [string, any, string]); - - allValues = Object.entries(item) - .filter(([key, value]) => value !== null && value !== undefined && value !== "") - .map(([key, value]) => [key, value, ""] as [string, any, string]); - } - - return ( -
- {/* 요약 정보 */} -
-
-
toggleRightItemExpansion(itemId)} - > -
- {firstValues.map(([key, value, label], idx) => { - // 포맷 설정 및 볼드 설정 찾기 - const colConfig = rightColumns?.find((c) => c.name === key); - const format = colConfig?.format; - const boldValue = colConfig?.bold ?? false; - - // 🆕 포맷 적용 (날짜/숫자/카테고리) - const displayValue = formatCellValue( - key, - value, - rightCategoryMappings, - format, - ); - - const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true; - - return ( -
- {showLabel && ( - - {label || getColumnLabel(key)}: - - )} - - {displayValue} - -
- ); - })} -
-
-
- {/* 수정 버튼 */} - {!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && ( - - )} - {/* 삭제 버튼 */} - {!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( - - )} - {/* 확장/접기 버튼 */} - -
-
-
- - {/* 상세 정보 (확장 시 표시) */} - {isExpanded && ( -
-
전체 상세 정보
-
- - - {allValues.map(([key, value, label]) => { - // 포맷 설정 찾기 - const colConfig = rightColumns?.find((c) => c.name === key); - const format = colConfig?.format; - - // 🆕 포맷 적용 (날짜/숫자/카테고리) - const displayValue = formatCellValue( - key, - value, - rightCategoryMappings, - format, - ); - - return ( - - - - - ); - })} - -
- {label || getColumnLabel(key)} - {displayValue}
-
-
- )} -
- ); - })} +
{String(value)}
- ) : ( -
- {rightSearchQuery ? ( - <> -

검색 결과가 없습니다.

-

다른 검색어를 입력해보세요.

- - ) : ( - "관련 데이터가 없습니다." - )} -
- ); - })() - ) : ( - // 상세 모드: 단일 객체를 상세 정보로 표시 - (() => { - const rightColumns = componentConfig.rightPanel?.columns; - let displayEntries: [string, any, string][] = []; - - if (rightColumns && rightColumns.length > 0) { - console.log("🔍 [디버깅] 상세 모드 표시 로직:"); - console.log(" 📋 rightData 전체:", rightData); - console.log(" 📋 rightData keys:", Object.keys(rightData)); - console.log( - " ⚙️ 설정된 컬럼:", - rightColumns.map((c) => `${c.name} (${c.label})`), - ); - - // 설정된 컬럼만 표시 - displayEntries = rightColumns - .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) - let value = rightData[col.name]; - console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); - - if (value === undefined && col.name.includes(".")) { - const columnName = col.name.split(".").pop(); - value = rightData[columnName || ""]; - console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); - } - - return [col.name, value, col.label] as [string, any, string]; - }) - .filter(([key, value]) => { - const filtered = value === null || value === undefined || value === ""; - if (filtered) { - console.log(` ❌ 필터링됨: "${key}" (값: ${value})`); - } - return !filtered; - }); - - console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); - } else { - // 설정 없으면 모든 컬럼 표시 - displayEntries = Object.entries(rightData) - .filter(([_, value]) => value !== null && value !== undefined && value !== "") - .map(([key, value]) => [key, value, ""] as [string, any, string]); - console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); - } - - return ( -
- {displayEntries.map(([key, value, label]) => ( -
-
- {label || getColumnLabel(key)} -
-
{String(value)}
-
- ))} -
- ); - })() - ) - ) : selectedLeftItem && isDesignMode ? ( - // 디자인 모드: 샘플 데이터 -
-
-

{selectedLeftItem.name} 상세 정보

-
-
- 항목 1: - 값 1 -
-
- 항목 2: - 값 2 -
-
- 항목 3: - 값 3 -
-
+ ))} +
+ ); + })() + ) + ) : selectedLeftItem && isDesignMode ? ( + // 디자인 모드: 샘플 데이터 +
+
+

{selectedLeftItem.name} 상세 정보

+
+
+ 항목 1: + 값 1 +
+
+ 항목 2: + 값 2 +
+
+ 항목 3: + 값 3
- ) : ( - // 선택 없음 -
-
-

좌측에서 항목을 선택하세요

-

선택한 항목의 상세 정보가 여기에 표시됩니다

-
-
- )} - +
+
+ ) : ( + // 선택 없음 +
+
+

좌측에서 항목을 선택하세요

+

선택한 항목의 상세 정보가 여기에 표시됩니다

+
+
)} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 088d0450..78abf111 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -6,7 +6,6 @@ import { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { codeCache } from "@/lib/caching/codeCache"; -import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { getFullImageUrl } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; @@ -67,7 +66,6 @@ import { useAuth } from "@/hooks/useAuth"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; -import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; // ======================================== // 인터페이스 @@ -244,11 +242,6 @@ export const TableListComponent: React.FC = ({ parentTabsComponentId, companyCode, }) => { - // ======================================== - // 다국어 번역 훅 - // ======================================== - const { getTranslatedText } = useScreenMultiLang(); - // ======================================== // 설정 및 스타일 // ======================================== @@ -481,7 +474,6 @@ export const TableListComponent: React.FC = ({ } // 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용) - // 🆕 다중 값 지원: 셀 값이 "A,B,C" 형태일 때, 필터에서 "A"를 선택하면 해당 행도 표시 if (Object.keys(headerFilters).length > 0) { result = result.filter((row) => { return Object.entries(headerFilters).every(([columnName, values]) => { @@ -491,16 +483,7 @@ export const TableListComponent: React.FC = ({ const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; - // 정확히 일치하는 경우 - if (values.has(cellStr)) return true; - - // 다중 값인 경우: 콤마로 분리해서 하나라도 포함되면 true - if (cellStr.includes(",")) { - const cellValues = cellStr.split(",").map(v => v.trim()); - return cellValues.some(v => values.has(v)); - } - - return false; + return values.has(cellStr); }); }); } @@ -871,55 +854,17 @@ export const TableListComponent: React.FC = ({ }; // 화면 컨텍스트에 데이터 제공자/수신자로 등록 - // 🔧 dataProvider와 dataReceiver를 의존성에 포함하지 않고, - // 대신 data와 selectedRows가 변경될 때마다 재등록하여 최신 클로저 참조 useEffect(() => { if (screenContext && component.id) { - // 🔧 매번 새로운 dataProvider를 등록하여 최신 selectedRows 참조 - const currentDataProvider: DataProvidable = { - componentId: component.id, - componentType: "table-list", - getSelectedData: () => { - const selectedData = filteredData.filter((row) => { - const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || ""); - return selectedRows.has(rowId); - }); - console.log("📊 [TableList] getSelectedData 호출:", { - componentId: component.id, - selectedRowsSize: selectedRows.size, - filteredDataLength: filteredData.length, - resultLength: selectedData.length, - }); - return selectedData; - }, - getAllData: () => filteredData, - clearSelection: () => { - setSelectedRows(new Set()); - setIsAllSelected(false); - }, - }; - - const currentDataReceiver: DataReceivable = { - componentId: component.id, - componentType: "table", - receiveData: dataReceiver.receiveData, - getData: () => data, - }; - - screenContext.registerDataProvider(component.id, currentDataProvider); - screenContext.registerDataReceiver(component.id, currentDataReceiver); - - console.log("✅ [TableList] ScreenContext에 등록:", { - componentId: component.id, - selectedRowsSize: selectedRows.size, - }); + screenContext.registerDataProvider(component.id, dataProvider); + screenContext.registerDataReceiver(component.id, dataReceiver); return () => { screenContext.unregisterDataProvider(component.id); screenContext.unregisterDataReceiver(component.id); }; } - }, [screenContext, component.id, data, selectedRows, filteredData, tableConfig.selectedTable]); + }, [screenContext, component.id, data, selectedRows]); // 분할 패널 컨텍스트에 데이터 수신자로 등록 // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) @@ -1086,16 +1031,14 @@ export const TableListComponent: React.FC = ({ onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정 // 틀고정 컬럼 관련 frozenColumnCount, // 현재 틀고정 컬럼 수 - onFrozenColumnCountChange: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => { + onFrozenColumnCountChange: (count: number) => { setFrozenColumnCount(count); // 체크박스 컬럼은 항상 틀고정에 포함 const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; // 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정 - // updatedColumns가 전달되면 그것을 사용, 아니면 columnsToRegister 사용 - const colsToUse = updatedColumns || columnsToRegister; - const visibleCols = colsToUse + const visibleCols = columnsToRegister .filter((col) => col.visible !== false) - .map((col) => col.columnName || (col as any).field); + .map((col) => col.columnName || col.field); const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)]; setFrozenColumns(newFrozenColumns); }, @@ -2106,7 +2049,7 @@ export const TableListComponent: React.FC = ({ return row.id || row.uuid || `row-${index}`; }; - const handleRowSelection = (rowKey: string, checked: boolean, rowData?: any) => { + const handleRowSelection = (rowKey: string, checked: boolean) => { const newSelectedRows = new Set(selectedRows); if (checked) { newSelectedRows.add(rowKey); @@ -2149,31 +2092,6 @@ export const TableListComponent: React.FC = ({ }); } - // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장/해제 (체크박스 선택 시에도 작동) - const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; - if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { - if (checked && selectedRowsData.length > 0) { - // 선택된 경우: 첫 번째 선택된 데이터 저장 (또는 전달된 rowData) - const dataToStore = rowData || selectedRowsData[selectedRowsData.length - 1]; - splitPanelContext.setSelectedLeftData(dataToStore); - console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 저장:", { - rowKey, - dataToStore, - }); - } else if (!checked && selectedRowsData.length === 0) { - // 모든 선택이 해제된 경우: 데이터 초기화 - splitPanelContext.setSelectedLeftData(null); - console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 초기화"); - } else if (selectedRowsData.length > 0) { - // 일부 선택 해제된 경우: 남은 첫 번째 데이터로 업데이트 - splitPanelContext.setSelectedLeftData(selectedRowsData[0]); - console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 업데이트:", { - remainingCount: selectedRowsData.length, - firstData: selectedRowsData[0], - }); - } - } - const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index))); setIsAllSelected(allRowsSelected && filteredData.length > 0); }; @@ -2243,8 +2161,35 @@ export const TableListComponent: React.FC = ({ const rowKey = getRowKey(row, index); const isCurrentlySelected = selectedRows.has(rowKey); - // handleRowSelection에서 분할 패널 데이터 처리도 함께 수행됨 - handleRowSelection(rowKey, !isCurrentlySelected, row); + handleRowSelection(rowKey, !isCurrentlySelected); + + // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) + // disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달) + // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음) + const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; + + console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", { + splitPanelPosition, + currentSplitPosition, + effectiveSplitPosition, + hasSplitPanelContext: !!splitPanelContext, + disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer, + }); + + if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { + if (!isCurrentlySelected) { + // 선택된 경우: 데이터 저장 + splitPanelContext.setSelectedLeftData(row); + console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", { + row, + parentDataMapping: splitPanelContext.parentDataMapping, + }); + } else { + // 선택 해제된 경우: 데이터 초기화 + splitPanelContext.setSelectedLeftData(null); + console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화"); + } + } console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected }); }; @@ -2311,176 +2256,30 @@ export const TableListComponent: React.FC = ({ // 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후) const startEditingRef = useRef<() => void>(() => {}); - // 🆕 카테고리 라벨 매핑 (API에서 가져온 것) - const [categoryLabelCache, setCategoryLabelCache] = useState>({}); - - // 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함) + // 🆕 각 컬럼의 고유값 목록 계산 const columnUniqueValues = useMemo(() => { - const result: Record> = {}; + const result: Record = {}; if (data.length === 0) return result; - // 🆕 전체 데이터에서 개별 값 -> 라벨 매핑 테이블 구축 (다중 값 처리용) - const globalLabelMap: Record> = {}; - (tableConfig.columns || []).forEach((column: { columnName: string }) => { if (column.columnName === "__checkbox__") return; const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - // 라벨 컬럼 후보들 (백엔드에서 _name, _label, _value_label 등으로 반환할 수 있음) - const labelColumnCandidates = [ - `${column.columnName}_name`, // 예: division_name - `${column.columnName}_label`, // 예: division_label - `${column.columnName}_value_label`, // 예: division_value_label - ]; - const valuesMap = new Map(); // value -> label - const singleValueLabelMap = new Map(); // 개별 값 -> 라벨 (다중값 처리용) + const values = new Set(); - // 1차: 모든 데이터에서 개별 값 -> 라벨 매핑 수집 (단일값 + 다중값 모두) data.forEach((row) => { const val = row[mappedColumnName]; if (val !== null && val !== undefined && val !== "") { - const valueStr = String(val); - - // 라벨 컬럼에서 라벨 찾기 - let labelStr = ""; - for (const labelCol of labelColumnCandidates) { - if (row[labelCol] && row[labelCol] !== "") { - labelStr = String(row[labelCol]); - break; - } - } - - // 단일 값인 경우 - if (!valueStr.includes(",")) { - if (labelStr) { - singleValueLabelMap.set(valueStr, labelStr); - } - } else { - // 다중 값인 경우: 값과 라벨을 각각 분리해서 매핑 - const individualValues = valueStr.split(",").map(v => v.trim()); - const individualLabels = labelStr ? labelStr.split(",").map(l => l.trim()) : []; - - // 값과 라벨 개수가 같으면 1:1 매핑 - if (individualValues.length === individualLabels.length) { - individualValues.forEach((v, idx) => { - if (individualLabels[idx] && !singleValueLabelMap.has(v)) { - singleValueLabelMap.set(v, individualLabels[idx]); - } - }); - } - } + values.add(String(val)); } }); - // 2차: 모든 값 처리 (다중 값 포함) - 필터 목록용 - data.forEach((row) => { - const val = row[mappedColumnName]; - if (val !== null && val !== undefined && val !== "") { - const valueStr = String(val); - - // 콤마로 구분된 다중 값인지 확인 - if (valueStr.includes(",")) { - // 다중 값: 각각 분리해서 개별 라벨 찾기 - const individualValues = valueStr.split(",").map(v => v.trim()); - // 🆕 singleValueLabelMap → categoryLabelCache 순으로 라벨 찾기 - const individualLabels = individualValues.map(v => - singleValueLabelMap.get(v) || categoryLabelCache[v] || v - ); - valuesMap.set(valueStr, individualLabels.join(", ")); - } else { - // 단일 값: 매핑에서 찾거나 캐시에서 찾거나 원본 사용 - const label = singleValueLabelMap.get(valueStr) || categoryLabelCache[valueStr] || valueStr; - valuesMap.set(valueStr, label); - } - } - }); - - globalLabelMap[column.columnName] = singleValueLabelMap; - - // value-label 쌍으로 저장하고 라벨 기준 정렬 - result[column.columnName] = Array.from(valuesMap.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label)); + result[column.columnName] = Array.from(values).sort(); }); return result; - }, [data, tableConfig.columns, joinColumnMapping, categoryLabelCache]); - - // 🆕 라벨을 못 찾은 CATEGORY_ 코드들을 API로 조회 - useEffect(() => { - const unlabeledCodes = new Set(); - - // columnUniqueValues에서 라벨이 코드 그대로인 항목 찾기 - Object.values(columnUniqueValues).forEach(items => { - items.forEach(item => { - // 라벨에 CATEGORY_가 포함되어 있으면 라벨을 못 찾은 것 - if (item.label.includes("CATEGORY_")) { - // 콤마로 분리해서 개별 코드 추출 - const codes = item.label.split(",").map(c => c.trim()); - codes.forEach(code => { - if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) { - unlabeledCodes.add(code); - } - }); - } - }); - }); - - if (unlabeledCodes.size === 0) return; - - // API로 라벨 조회 - const fetchLabels = async () => { - try { - const response = await getCategoryLabelsByCodes(Array.from(unlabeledCodes)); - if (response.success && response.data) { - setCategoryLabelCache(prev => ({ ...prev, ...response.data })); - } - } catch (error) { - console.error("카테고리 라벨 조회 실패:", error); - } - }; - - fetchLabels(); - }, [columnUniqueValues, categoryLabelCache]); - - // 🆕 데이터에서 CATEGORY_ 코드를 찾아 라벨 미리 로드 (테이블 셀 렌더링용) - useEffect(() => { - if (data.length === 0) return; - - const categoryCodesToFetch = new Set(); - - // 모든 데이터 행에서 CATEGORY_ 코드 수집 - data.forEach((row) => { - Object.entries(row).forEach(([key, value]) => { - if (value && typeof value === "string") { - // 콤마로 구분된 다중 값도 처리 - const codes = value.split(",").map((v) => v.trim()); - codes.forEach((code) => { - if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) { - categoryCodesToFetch.add(code); - } - }); - } - }); - }); - - if (categoryCodesToFetch.size === 0) return; - - // API로 라벨 조회 - const fetchLabels = async () => { - try { - const response = await getCategoryLabelsByCodes(Array.from(categoryCodesToFetch)); - if (response.success && response.data && Object.keys(response.data).length > 0) { - setCategoryLabelCache((prev) => ({ ...prev, ...response.data })); - } - } catch (error) { - console.error("CATEGORY_ 라벨 조회 실패:", error); - } - }; - - fetchLabels(); - }, [data, categoryLabelCache]); + }, [data, tableConfig.columns, joinColumnMapping]); // 🆕 헤더 필터 토글 const toggleHeaderFilter = useCallback((columnName: string, value: string) => { @@ -4125,7 +3924,7 @@ export const TableListComponent: React.FC = ({ if (enterRow) { const rowKey = getRowKey(enterRow, rowIndex); const isCurrentlySelected = selectedRows.has(rowKey); - handleRowSelection(rowKey, !isCurrentlySelected, enterRow); + handleRowSelection(rowKey, !isCurrentlySelected); } break; case " ": // Space @@ -4135,7 +3934,7 @@ export const TableListComponent: React.FC = ({ if (spaceRow) { const currentRowKey = getRowKey(spaceRow, rowIndex); const isChecked = selectedRows.has(currentRowKey); - handleRowSelection(currentRowKey, !isChecked, spaceRow); + handleRowSelection(currentRowKey, !isChecked); } break; case "F2": @@ -4349,7 +4148,7 @@ export const TableListComponent: React.FC = ({ return ( handleRowSelection(rowKey, checked as boolean, row)} + onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)} aria-label={`행 ${index + 1} 선택`} /> ); @@ -4638,36 +4437,10 @@ export const TableListComponent: React.FC = ({ case "boolean": return value ? "예" : "아니오"; default: - // 🆕 CATEGORY_ 코드 자동 변환 (inputType이 category가 아니어도) - const strValue = String(value); - if (strValue.startsWith("CATEGORY_")) { - // rowData에서 _label 필드 찾기 - if (rowData) { - const labelFieldCandidates = [ - `${column.columnName}_label`, - `${column.columnName}_name`, - `${column.columnName}_value_label`, - ]; - for (const labelField of labelFieldCandidates) { - if (rowData[labelField] && rowData[labelField] !== "") { - return String(rowData[labelField]); - } - } - } - // categoryMappings에서 찾기 - const mapping = categoryMappings[column.columnName]; - if (mapping && mapping[strValue]) { - return mapping[strValue].label; - } - // categoryLabelCache에서 찾기 (필터용 캐시) - if (categoryLabelCache[strValue]) { - return categoryLabelCache[strValue]; - } - } - return strValue; + return String(value); } }, - [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings, categoryLabelCache], + [columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings], ); // ======================================== @@ -4806,22 +4579,9 @@ export const TableListComponent: React.FC = ({ }); setColumnWidths(newWidths); - // 틀고정 컬럼 업데이트 (보이는 컬럼 기준으로 처음 N개를 틀고정) - // 기존 frozen 개수를 유지하면서, 숨겨진 컬럼을 제외한 보이는 컬럼 중 처음 N개를 틀고정 - const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; - const visibleCols = config.columns - .filter((col) => col.visible && col.columnName !== "__checkbox__") - .map((col) => col.columnName); - - // 현재 설정된 frozen 컬럼 개수 (체크박스 제외) - const currentFrozenCount = config.columns.filter( - (col) => col.frozen && col.columnName !== "__checkbox__" - ).length; - - // 보이는 컬럼 중 처음 currentFrozenCount개를 틀고정으로 설정 - const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, currentFrozenCount)]; + // 틀고정 컬럼 업데이트 + const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); setFrozenColumns(newFrozenColumns); - setFrozenColumnCount(currentFrozenCount); // 그리드선 표시 업데이트 setShowGridLines(config.showGridLines); @@ -5865,10 +5625,7 @@ export const TableListComponent: React.FC = ({ rowSpan={2} className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm" > - {/* langKey가 있으면 다국어 번역 사용 */} - {(column as any).langKey - ? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.columnName) - : columnLabels[column.columnName] || column.columnName} + {columnLabels[column.columnName] || column.columnName}