diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 8b1f859d..45d888e1 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1599,3 +1599,96 @@ export async function toggleLogTable( res.status(500).json(response); } } + +/** + * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 + * + * @route GET /api/table-management/menu/:menuObjid/category-columns + * @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회 + */ +export async function getCategoryColumnsByMenu( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid } = req.params; + const companyCode = req.user?.companyCode; + + logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode }); + + if (!menuObjid) { + return res.status(400).json({ + success: false, + message: "메뉴 OBJID가 필요합니다.", + }); + } + + // 1. 형제 메뉴 조회 + const { getSiblingMenuObjids } = await import("../services/menuService"); + const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid)); + + logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids }); + + // 2. 형제 메뉴들이 사용하는 테이블 조회 + const { getPool } = await import("../database/db"); + const pool = getPool(); + + const tablesQuery = ` + SELECT DISTINCT table_name + FROM screen_definitions + WHERE menu_objid = ANY($1) + AND company_code = $2 + AND table_name IS NOT NULL + `; + + const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]); + const tableNames = tablesResult.rows.map((row: any) => row.table_name); + + logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames }); + + if (tableNames.length === 0) { + return res.json({ + success: true, + data: [], + message: "형제 메뉴에 연결된 테이블이 없습니다.", + }); + } + + // 3. 테이블들의 카테고리 타입 컬럼 조회 + const columnsQuery = ` + SELECT + table_name AS "tableName", + column_name AS "columnName", + column_label AS "columnLabel", + input_type AS "inputType" + FROM table_type_columns + WHERE table_name = ANY($1) + AND company_code = $2 + AND input_type = 'category' + ORDER BY table_name, column_name + `; + + const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); + + logger.info("✅ 카테고리 컬럼 조회 완료", { + columnCount: columnsResult.rows.length + }); + + res.json({ + success: true, + data: columnsResult.rows, + message: "카테고리 컬럼 조회 성공", + }); + } catch (error: any) { + logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패", { + error: error.message, + errorStack: error.stack, + }); + + res.status(500).json({ + success: false, + message: "카테고리 컬럼 조회에 실패했습니다.", + error: error.message, + }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 0ec8c162..5ea98489 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -23,6 +23,7 @@ import { getLogConfig, getLogData, toggleLogTable, + getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -187,4 +188,14 @@ router.get("/tables/:tableName/log", getLogData); */ router.post("/tables/:tableName/log/toggle", toggleLogTable); +// ======================================== +// 메뉴 기반 카테고리 관리 API +// ======================================== + +/** + * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 + * GET /api/table-management/menu/:menuObjid/category-columns + */ +router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); + export default router; diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 5c2c587b..6fc7bab8 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -473,6 +473,7 @@ export default function ScreenViewPage() { userId={user?.userId} userName={userName} companyCode={companyCode} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 51c070cd..e87dc73d 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -274,10 +274,14 @@ function AppLayoutInner({ children }: AppLayoutProps) { // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - // 관리자 모드 상태를 쿼리 파라미터로 전달 - const screenPath = isAdminMode - ? `/screens/${firstScreen.screenId}?mode=admin` - : `/screens/${firstScreen.screenId}`; + // 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달 + const params = new URLSearchParams(); + if (isAdminMode) { + params.set("mode", "admin"); + } + params.set("menuObjid", menuObjid.toString()); + + const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`; router.push(screenPath); if (isMobile) { diff --git a/frontend/components/screen/widgets/CategoryWidget.tsx b/frontend/components/screen/widgets/CategoryWidget.tsx index 3e1c7f7b..2974ed60 100644 --- a/frontend/components/screen/widgets/CategoryWidget.tsx +++ b/frontend/components/screen/widgets/CategoryWidget.tsx @@ -6,20 +6,52 @@ import { CategoryValueManager } from "@/components/table-category/CategoryValueM import { GripVertical } from "lucide-react"; interface CategoryWidgetProps { - widgetId: string; - tableName: string; // 현재 화면의 테이블 - menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) + widgetId?: string; + tableName?: string; // 현재 화면의 테이블 (옵션 - 형제 메뉴 전체 표시) + menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) - 필수 + component?: any; // DynamicComponentRenderer에서 전달되는 컴포넌트 정보 + [key: string]: any; // 추가 props 허용 } /** * 카테고리 관리 위젯 (좌우 분할) - * - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록 + * - 좌측: 형제 메뉴들의 모든 카테고리 타입 컬럼 목록 (메뉴 스코프) * - 우측: 선택된 컬럼의 카테고리 값 관리 (메뉴 스코프) */ -export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidgetProps) { +export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...props }: CategoryWidgetProps) { + // menuObjid가 없으면 경고 로그 + React.useEffect(() => { + console.log("🔍 CategoryWidget 받은 props:", { + widgetId, + tableName, + menuObjid, + hasComponent: !!component, + propsKeys: Object.keys(props), + propsMenuObjid: props.menuObjid, + allProps: { widgetId, tableName, menuObjid, ...props }, + }); + + if (!menuObjid && !props.menuObjid) { + console.warn("⚠️ CategoryWidget: menuObjid가 전달되지 않았습니다", { + component, + props, + allAvailableProps: { widgetId, tableName, menuObjid, ...props } + }); + } else { + console.log("✅ CategoryWidget 렌더링", { + widgetId, + tableName, + menuObjid: menuObjid || props.menuObjid + }); + } + }, [menuObjid, widgetId, tableName, component, props]); + // menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값 + const effectiveMenuObjid = menuObjid || props.menuObjid; + const [selectedColumn, setSelectedColumn] = useState<{ columnName: string; columnLabel: string; + tableName: string; } | null>(null); const [leftWidth, setLeftWidth] = useState(15); // 초기값 15% @@ -67,10 +99,10 @@ export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidge - setSelectedColumn({ columnName, columnLabel }) + onColumnSelect={(columnName, columnLabel, tableName) => + setSelectedColumn({ columnName, columnLabel, tableName }) } - menuObjid={menuObjid} + menuObjid={effectiveMenuObjid} /> @@ -86,10 +118,10 @@ export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidge
{selectedColumn ? ( ) : (
diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index db6af71c..0e25643e 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -6,6 +6,7 @@ import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { FolderTree, Loader2 } from "lucide-react"; interface CategoryColumn { + tableName: string; columnName: string; columnLabel: string; inputType: string; @@ -13,95 +14,84 @@ interface CategoryColumn { } interface CategoryColumnListProps { - tableName: string; + tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시) selectedColumn: string | null; - onColumnSelect: (columnName: string, columnLabel: string) => void; - menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) + onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void; + menuObjid?: number; // 현재 메뉴 OBJID (필수) } /** * 카테고리 컬럼 목록 (좌측 패널) - * - 현재 테이블에서 input_type='category'인 컬럼들을 표시 (메뉴 스코프) + * - 형제 메뉴들의 모든 카테고리 타입 컬럼을 표시 (메뉴 스코프) */ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) { const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); useEffect(() => { - loadCategoryColumns(); - }, [tableName]); + if (menuObjid) { + loadCategoryColumnsByMenu(); + } else { + console.warn("⚠️ menuObjid가 없어서 카테고리 컬럼을 로드할 수 없습니다"); + setColumns([]); + } + }, [menuObjid]); - const loadCategoryColumns = async () => { + const loadCategoryColumnsByMenu = async () => { setIsLoading(true); try { - // table_type_columns에서 input_type = 'category'인 컬럼 조회 - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + console.log("🔍 형제 메뉴의 카테고리 컬럼 조회 시작", { menuObjid }); + + // 새 API: 형제 메뉴들의 카테고리 컬럼 조회 + const response = await apiClient.get(`/table-management/menu/${menuObjid}/category-columns`); - console.log("🔍 테이블 컬럼 API 응답:", { - tableName, + console.log("✅ 메뉴별 카테고리 컬럼 API 응답:", { + menuObjid, response: response.data, - type: typeof response.data, - isArray: Array.isArray(response.data), }); - // API 응답 구조 파싱 (여러 가능성 대응) - let allColumns: any[] = []; + let categoryColumns: any[] = []; - if (Array.isArray(response.data)) { - // response.data가 직접 배열인 경우 - allColumns = response.data; - } else if (response.data.data && response.data.data.columns && Array.isArray(response.data.data.columns)) { - // response.data.data.columns가 배열인 경우 (table-management API) - allColumns = response.data.data.columns; - } else if (response.data.data && Array.isArray(response.data.data)) { - // response.data.data가 배열인 경우 - allColumns = response.data.data; - } else if (response.data.columns && Array.isArray(response.data.columns)) { - // response.data.columns가 배열인 경우 - allColumns = response.data.columns; + if (response.data.success && response.data.data) { + categoryColumns = response.data.data; + } else if (Array.isArray(response.data)) { + categoryColumns = response.data; } else { console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data); - allColumns = []; + categoryColumns = []; } - console.log("🔍 파싱된 컬럼 목록:", { - totalColumns: allColumns.length, - sample: allColumns.slice(0, 3), - }); - - // category 타입만 필터링 - const categoryColumns = allColumns.filter( - (col: any) => col.inputType === "category" || col.input_type === "category", - ); - - console.log("✅ 카테고리 컬럼:", { + console.log("✅ 카테고리 컬럼 파싱 완료:", { count: categoryColumns.length, columns: categoryColumns.map((c: any) => ({ - name: c.columnName || c.column_name, - type: c.inputType || c.input_type, + table: c.tableName, + column: c.columnName, + label: c.columnLabel, })), }); + // 각 컬럼의 값 개수 가져오기 const columnsWithCount = await Promise.all( categoryColumns.map(async (col: any) => { - const colName = col.columnName || col.column_name; - const colLabel = col.columnLabel || col.column_label || col.displayName || colName; + const colTable = col.tableName; + const colName = col.columnName; + const colLabel = col.columnLabel || colName; - // 각 컬럼의 값 개수 가져오기 let valueCount = 0; try { - const valuesResult = await getCategoryValues(tableName, colName, false, menuObjid); + const valuesResult = await getCategoryValues(colTable, colName, false, menuObjid); if (valuesResult.success && valuesResult.data) { valueCount = valuesResult.data.length; } } catch (error) { - console.error(`항목 개수 조회 실패 (${colName}):`, error); + console.error(`항목 개수 조회 실패 (${colTable}.${colName}):`, error); } return { + tableName: colTable, columnName: colName, columnLabel: colLabel, - inputType: col.inputType || col.input_type, + inputType: col.inputType, valueCount, }; }), @@ -112,7 +102,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; - onColumnSelect(firstCol.columnName, firstCol.columnLabel); + onColumnSelect(firstCol.columnName, firstCol.columnLabel, firstCol.tableName); } } catch (error) { console.error("❌ 카테고리 컬럼 조회 실패:", error); @@ -153,27 +143,31 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
- {columns.map((column) => ( -
onColumnSelect(column.columnName, column.columnLabel || column.columnName)} - className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ - selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" - }`} - > -
- -
-

{column.columnLabel || column.columnName}

+ {columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + return ( +
onColumnSelect(column.columnName, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ + selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+

{column.tableName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} +
- - {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} -
-
- ))} + ); + })}
); diff --git a/frontend/hooks/useMenu.ts b/frontend/hooks/useMenu.ts index 48c93ce7..2258f6c4 100644 --- a/frontend/hooks/useMenu.ts +++ b/frontend/hooks/useMenu.ts @@ -176,7 +176,8 @@ export const useMenu = (user: any, authLoading: boolean) => { if (assignedScreens.length > 0) { // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - router.push(`/screens/${firstScreen.screenId}`); + // menuObjid를 쿼리 파라미터로 전달 + router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`); return; } }