From a06f2eb52ca6f7ebcf19272ae13d33b209ed8d73 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 27 Jan 2026 23:02:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DISTINCT=20=EA=B0=92=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=84=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블 컬럼의 DISTINCT 값을 조회하는 API를 추가하였습니다. 이 API는 특정 테이블과 컬럼에서 DISTINCT 값을 반환하여 선택박스 옵션으로 사용할 수 있도록 합니다. - API 호출 시 멀티테넌시를 고려하여 회사 코드에 따라 필터링을 적용하였습니다. - 관련된 라우터 설정을 추가하여 API 접근을 가능하게 하였습니다. - 프론트엔드에서 DISTINCT 값을 조회할 수 있도록 UnifiedSelect 컴포넌트를 업데이트하였습니다. --- .../src/controllers/entitySearchController.ts | 101 +++++++ backend-node/src/routes/entitySearchRoutes.ts | 8 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 8 + .../components/screen/widgets/TabsWidget.tsx | 166 +++++++++-- frontend/components/unified/UnifiedSelect.tsx | 13 + .../AggregationWidgetComponent.tsx | 277 ++++++++++++++---- .../AggregationWidgetConfigPanel.tsx | 112 ++++++- .../lib/utils/getComponentConfigPanel.tsx | 26 +- frontend/lib/utils/webTypeMapping.ts | 6 + 9 files changed, 624 insertions(+), 93 deletions(-) diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index f12ee16d..29170e9f 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -3,6 +3,107 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +/** + * 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용) + * GET /api/entity/:tableName/distinct/:columnName + * + * 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환 + */ +export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) { + try { + const { tableName, columnName } = req.params; + const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼 + + // 유효성 검증 + if (!tableName || tableName === "undefined" || tableName === "null") { + return res.status(400).json({ + success: false, + message: "테이블명이 지정되지 않았습니다.", + }); + } + + if (!columnName || columnName === "undefined" || columnName === "null") { + return res.status(400).json({ + success: false, + message: "컬럼명이 지정되지 않았습니다.", + }); + } + + const companyCode = req.user!.companyCode; + const pool = getPool(); + + // 테이블의 실제 컬럼 목록 조회 + const columnsResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [tableName] + ); + const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name)); + + // 요청된 컬럼 검증 + if (!existingColumns.has(columnName)) { + return res.status(400).json({ + success: false, + message: `테이블 "${tableName}"에 컬럼 "${columnName}"이 존재하지 않습니다.`, + }); + } + + // 라벨 컬럼 결정 (지정되지 않으면 값 컬럼과 동일) + const effectiveLabelColumn = labelColumn && existingColumns.has(labelColumn as string) + ? labelColumn as string + : columnName; + + // WHERE 조건 (멀티테넌시) + const whereConditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode !== "*" && existingColumns.has("company_code")) { + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + // NULL 제외 + whereConditions.push(`"${columnName}" IS NOT NULL`); + whereConditions.push(`"${columnName}" != ''`); + + const whereClause = whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + // DISTINCT 쿼리 실행 + const query = ` + SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label + FROM "${tableName}" + ${whereClause} + ORDER BY "${effectiveLabelColumn}" ASC + LIMIT 500 + `; + + const result = await pool.query(query, params); + + logger.info("컬럼 DISTINCT 값 조회 성공", { + tableName, + columnName, + labelColumn: effectiveLabelColumn, + companyCode, + rowCount: result.rowCount, + }); + + res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("컬럼 DISTINCT 값 조회 오류", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ success: false, message: error.message }); + } +} + /** * 엔티티 옵션 조회 API (UnifiedSelect용) * GET /api/entity/:tableName/options diff --git a/backend-node/src/routes/entitySearchRoutes.ts b/backend-node/src/routes/entitySearchRoutes.ts index f75260e9..b3b11e5c 100644 --- a/backend-node/src/routes/entitySearchRoutes.ts +++ b/backend-node/src/routes/entitySearchRoutes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; -import { searchEntity, getEntityOptions } from "../controllers/entitySearchController"; +import { searchEntity, getEntityOptions, getDistinctColumnValues } from "../controllers/entitySearchController"; const router = Router(); @@ -21,3 +21,9 @@ export const entityOptionsRouter = Router(); */ entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions); +/** + * 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용) + * GET /api/entity/:tableName/distinct/:columnName + */ +entityOptionsRouter.get("/:tableName/distinct/:columnName", authenticateToken, getDistinctColumnValues); + diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index e867828d..8177bf7e 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -303,6 +303,14 @@ export const UnifiedPropertiesPanel: React.FC = ({ allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용 currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보 menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등) + // 🆕 집계 위젯 등에서 사용하는 컴포넌트 목록 + screenComponents={allComponents.map((comp: any) => ({ + id: comp.id, + componentType: comp.componentType || comp.type, + label: comp.label || comp.name || comp.id, + tableName: comp.componentConfig?.tableName || comp.tableName, + columnName: comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName, + }))} /> diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 1f165df2..14e8c1be 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,13 +1,20 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; -import { X } from "lucide-react"; -import type { TabsComponent, TabItem, TabInlineComponent } from "@/types/screen-management"; +import { X, Loader2 } from "lucide-react"; +import type { TabsComponent, TabItem, TabInlineComponent, ComponentData } from "@/types/screen-management"; import { cn } from "@/lib/utils"; import { useActiveTab } from "@/contexts/ActiveTabContext"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { screenApi } from "@/lib/api/screen"; + +// 확장된 TabItem 타입 (screenId 지원) +interface ExtendedTabItem extends TabItem { + screenId?: number; + screenName?: string; +} interface TabsWidgetProps { component: TabsComponent; @@ -15,10 +22,10 @@ interface TabsWidgetProps { style?: React.CSSProperties; menuObjid?: number; formData?: Record; - onFormDataChange?: (fieldName: string, value: any) => void; // DynamicComponentRenderer와 동일한 시그니처 - isDesignMode?: boolean; // 디자인 모드 여부 - onComponentSelect?: (tabId: string, componentId: string) => void; // 컴포넌트 선택 콜백 - selectedComponentId?: string; // 선택된 컴포넌트 ID + onFormDataChange?: (fieldName: string, value: any) => void; + isDesignMode?: boolean; + onComponentSelect?: (tabId: string, componentId: string) => void; + selectedComponentId?: string; } export function TabsWidget({ @@ -56,14 +63,45 @@ export function TabsWidget({ }; const [selectedTab, setSelectedTab] = useState(getInitialTab()); - const [visibleTabs, setVisibleTabs] = useState(tabs); + const [visibleTabs, setVisibleTabs] = useState(tabs as ExtendedTabItem[]); const [mountedTabs, setMountedTabs] = useState>(() => new Set([getInitialTab()])); + + // screenId 기반 화면 로드 상태 + const [screenLayouts, setScreenLayouts] = useState>({}); + const [screenLoadingStates, setScreenLoadingStates] = useState>({}); + const [screenErrors, setScreenErrors] = useState>({}); // 컴포넌트 탭 목록 변경 시 동기화 useEffect(() => { - setVisibleTabs(tabs.filter((tab) => !tab.disabled)); + setVisibleTabs((tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled)); }, [tabs]); + // screenId가 있는 탭의 화면 레이아웃 로드 + useEffect(() => { + const loadScreenLayouts = async () => { + for (const tab of visibleTabs) { + const extTab = tab as ExtendedTabItem; + // screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드 + if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) { + setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true })); + try { + const layoutData = await screenApi.getLayout(extTab.screenId); + if (layoutData && layoutData.components) { + setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components })); + } + } catch (error) { + console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); + setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); + } finally { + setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false })); + } + } + } + }; + + loadScreenLayouts(); + }, [visibleTabs, screenLayouts, screenLoadingStates]); + // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트 useEffect(() => { if (persistSelection && typeof window !== "undefined") { @@ -123,20 +161,110 @@ export function TabsWidget({ return `${baseClass} ${variantClass}`; }; - // 인라인 컴포넌트 렌더링 - const renderTabComponents = (tab: TabItem) => { - const components = tab.components || []; - - if (components.length === 0) { + // 탭 컨텐츠 렌더링 (screenId 또는 인라인 컴포넌트) + const renderTabContent = (tab: ExtendedTabItem) => { + const extTab = tab as ExtendedTabItem; + const inlineComponents = tab.components || []; + + // 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식 + if (extTab.screenId && inlineComponents.length === 0) { + // 로딩 중 + if (screenLoadingStates[tab.id]) { + return ( +
+ + 화면을 불러오는 중... +
+ ); + } + + // 에러 발생 + if (screenErrors[tab.id]) { + return ( +
+

{screenErrors[tab.id]}

+
+ ); + } + + // 화면 레이아웃이 로드된 경우 + const loadedComponents = screenLayouts[tab.id]; + if (loadedComponents && loadedComponents.length > 0) { + return renderScreenComponents(loadedComponents); + } + + // 아직 로드되지 않은 경우 return ( -
-

- {isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"} -

+
+
); } + + // 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식 + if (inlineComponents.length > 0) { + return renderInlineComponents(tab, inlineComponents); + } + + // 3. 둘 다 없는 경우 + return ( +
+

+ {isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"} +

+
+ ); + }; + // screenId로 로드한 화면 컴포넌트 렌더링 + const renderScreenComponents = (components: ComponentData[]) => { + // InteractiveScreenViewerDynamic 동적 로드 + const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic; + + // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 + const maxBottom = Math.max( + ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), + 300 + ); + const maxRight = Math.max( + ...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), + 400 + ); + + return ( +
+ {components.map((comp) => ( +
+ +
+ ))} +
+ ); + }; + + // 인라인 컴포넌트 렌더링 (v2 방식) + const renderInlineComponents = (tab: TabItem, components: TabInlineComponent[]) => { // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 const maxBottom = Math.max( ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), @@ -256,7 +384,7 @@ export function TabsWidget({ forceMount className={cn("h-full overflow-auto", !isActive && "hidden")} > - {shouldRender && renderTabComponents(tab)} + {shouldRender && renderTabContent(tab)} ); })} diff --git a/frontend/components/unified/UnifiedSelect.tsx b/frontend/components/unified/UnifiedSelect.tsx index de1652a4..d57ae9ad 100644 --- a/frontend/components/unified/UnifiedSelect.tsx +++ b/frontend/components/unified/UnifiedSelect.tsx @@ -618,6 +618,19 @@ export const UnifiedSelect = forwardRef( fetchedOptions = flattenTree(data.data); } } + } else if (source === "select" || source === "distinct") { + // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 + // tableName, columnName은 props에서 가져옴 + if (tableName && columnName) { + const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } } setOptions(fetchedOptions); diff --git a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx index bab8e691..0dbc5033 100644 --- a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx +++ b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx @@ -1,12 +1,13 @@ "use client"; -import React, { useState, useEffect, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { ComponentRendererProps } from "@/types/component"; import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType, FilterCondition, DataSourceType } from "./types"; import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; import { apiClient } from "@/lib/api/client"; +import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; interface AggregationWidgetComponentProps extends ComponentRendererProps { config?: AggregationWidgetConfig; @@ -16,6 +17,14 @@ interface AggregationWidgetComponentProps extends ComponentRendererProps { formData?: Record; // 선택된 행 데이터 selectedRows?: any[]; + // 선택된 행 전체 데이터 (표준 Props) + selectedRowsData?: any[]; + // 멀티테넌시용 회사 코드 + companyCode?: string; + // 새로고침 트리거 키 + refreshKey?: number; + // 새로고침 콜백 + onRefresh?: () => void; } /** @@ -107,11 +116,16 @@ export function AggregationWidgetComponent({ externalData, formData = {}, selectedRows = [], + selectedRowsData = [], + companyCode, + refreshKey, + onRefresh, }: AggregationWidgetComponentProps) { // 다국어 지원 const { getText } = useScreenMultiLang(); - const componentConfig: AggregationWidgetConfig = { + // useMemo로 config 병합 (매 렌더링마다 새 객체 생성 방지) + const componentConfig = useMemo(() => ({ dataSourceType: "table", items: [], layout: "horizontal", @@ -120,7 +134,7 @@ export function AggregationWidgetComponent({ gap: "16px", ...propsConfig, ...component?.config, - }; + }), [propsConfig, component?.config]); // 다국어 라벨 가져오기 const getItemLabel = (item: AggregationItem): string => { @@ -230,13 +244,13 @@ export function AggregationWidgetComponent({ } }, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]); - // 테이블 데이터 조회 (초기 로드) + // 테이블 데이터 조회 (초기 로드 + refreshKey 변경 시) useEffect(() => { if (dataSourceType === "table" && effectiveTableName && !isDesignMode) { fetchTableData(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataSourceType, effectiveTableName, isDesignMode]); + }, [dataSourceType, effectiveTableName, isDesignMode, refreshKey]); // 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때) const formDataKey = JSON.stringify(formData); @@ -260,16 +274,114 @@ export function AggregationWidgetComponent({ }, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]); // 선택된 행 집계 (dataSourceType === "selection"일 때) - // props로 전달된 selectedRows 사용 - const selectedRowsKey = JSON.stringify(selectedRows); + // props로 전달된 selectedRows 또는 selectedRowsData 사용 + // 길이 정보를 포함하여 전체 데이터 변경 감지 개선 + const selectedRowsKey = `${selectedRows?.length || 0}:${JSON.stringify(selectedRows?.slice(0, 5))}`; + const selectedRowsDataKey = `${selectedRowsData?.length || 0}:${JSON.stringify(selectedRowsData?.slice(0, 5))}`; useEffect(() => { - if (dataSourceType === "selection" && Array.isArray(selectedRows) && selectedRows.length > 0) { - setData(selectedRows); + // selectedRowsData가 있으면 우선 사용 (표준 Props) + const rowsToUse = selectedRowsData?.length > 0 ? selectedRowsData : selectedRows; + if (dataSourceType === "selection") { + if (Array.isArray(rowsToUse) && rowsToUse.length > 0) { + const filteredData = applyFilters( + rowsToUse, + filtersRef.current || [], + filterLogic, + formDataRef.current, + selectedRowsRef.current + ); + setData(filteredData); + } else { + // 선택 해제 시 빈 배열로 초기화 + setData([]); + } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataSourceType, selectedRowsKey]); + }, [dataSourceType, selectedRowsKey, selectedRowsDataKey, filterLogic]); - // 전역 선택 이벤트 수신 (dataSourceType === "selection"일 때) + // V2 이벤트 버스 구독 (selection 또는 component 타입일 때) + useEffect(() => { + if (isDesignMode) return; + if (dataSourceType !== "selection" && dataSourceType !== "component") return; + + // 핸들러 함수 정의 + const handleV2TableDataChange = (payload: any) => { + // component 타입: source가 dataSourceComponentId와 일치할 때만 + // selection 타입: 모든 테이블 데이터 변경 수신 + if (dataSourceType === "component" && payload.source !== dataSourceComponentId) { + return; + } + + if (Array.isArray(payload.data)) { + const filteredData = applyFilters( + payload.data, + filtersRef.current || [], + filterLogic, + formDataRef.current, + selectedRowsRef.current + ); + setData(filteredData); + } + }; + + const handleV2TableSelectionChange = (payload: any) => { + // component 타입: source가 dataSourceComponentId와 일치할 때만 + // selection 타입: 모든 선택 변경 수신 + if (dataSourceType === "component" && payload.source !== dataSourceComponentId) { + return; + } + + if (Array.isArray(payload.selectedRows)) { + const filteredData = applyFilters( + payload.selectedRows, + filtersRef.current || [], + filterLogic, + formDataRef.current, + selectedRowsRef.current + ); + setData(filteredData); + } + }; + + const handleV2RepeaterDataChange = (payload: any) => { + if (dataSourceType === "component" && payload.repeaterId !== dataSourceComponentId) { + return; + } + + if (Array.isArray(payload.data)) { + const filteredData = applyFilters( + payload.data, + filtersRef.current || [], + filterLogic, + formDataRef.current, + selectedRowsRef.current + ); + setData(filteredData); + } + }; + + // V2 이벤트 버스 구독 + const unsubscribeTableData = v2EventBus.subscribe( + V2_EVENTS.TABLE_DATA_CHANGE, + handleV2TableDataChange + ); + const unsubscribeTableSelection = v2EventBus.subscribe( + V2_EVENTS.TABLE_SELECTION_CHANGE, + handleV2TableSelectionChange + ); + const unsubscribeRepeaterData = v2EventBus.subscribe( + V2_EVENTS.REPEATER_DATA_CHANGE, + handleV2RepeaterDataChange + ); + + return () => { + unsubscribeTableData(); + unsubscribeTableSelection(); + unsubscribeRepeaterData(); + }; + }, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]); + + // 전역 선택 이벤트 수신 - 레거시 지원 (dataSourceType === "selection"일 때) useEffect(() => { if (dataSourceType !== "selection" || isDesignMode) return; @@ -346,7 +458,10 @@ export function AggregationWidgetComponent({ }, [dataSourceType, isDesignMode, filterLogic]); // 외부 데이터가 있으면 사용 - const externalDataKey = externalData ? JSON.stringify(externalData.slice(0, 5)) : null; // 첫 5개만 비교 + // 길이 정보를 포함하여 전체 데이터 변경 감지 개선 + const externalDataKey = externalData + ? `${externalData.length}:${JSON.stringify(externalData.slice(0, 5))}` + : null; useEffect(() => { if (externalData && Array.isArray(externalData)) { // 필터 적용 @@ -475,6 +590,61 @@ export function AggregationWidgetComponent({ }); }, [data, items, getText]); + // aggregationResults를 ref로 유지 (이벤트 핸들러에서 최신 값 참조) + const aggregationResultsRef = useRef(aggregationResults); + aggregationResultsRef.current = aggregationResults; + + // beforeFormSave 이벤트 리스너 (저장 시 집계 결과를 폼 데이터에 포함) + useEffect(() => { + if (isDesignMode) return; + + const handleBeforeFormSave = (event: CustomEvent) => { + const componentKey = component?.id || "aggregation_data"; + if (event.detail) { + // 집계 결과를 객체 형태로 저장 + const aggregationData: Record = {}; + aggregationResultsRef.current.forEach((result) => { + aggregationData[result.id] = { + label: result.label, + value: result.value, + formattedValue: result.formattedValue, + type: result.type, + }; + }); + event.detail.formData[componentKey] = aggregationData; + } + }; + + // V2 이벤트 버스 구독 + const unsubscribe = v2EventBus.subscribe( + V2_EVENTS.FORM_SAVE_COLLECT, + (payload) => { + const componentKey = component?.id || "aggregation_data"; + const aggregationData: Record = {}; + aggregationResultsRef.current.forEach((result) => { + aggregationData[result.id] = { + label: result.label, + value: result.value, + formattedValue: result.formattedValue, + type: result.type, + }; + }); + // V2 이벤트로 응답 + if (payload.formData) { + payload.formData[componentKey] = aggregationData; + } + } + ); + + // 레거시 이벤트도 지원 + window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + + return () => { + unsubscribe(); + window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener); + }; + }, [isDesignMode, component?.id]); + // 집계 타입에 따른 아이콘 const getIcon = (type: AggregationType) => { switch (type) { @@ -627,47 +797,52 @@ export function AggregationWidgetComponent({ } return ( -
- {aggregationResults.map((result, index) => ( -
- {showIcons && ( - {getIcon(result.type)} - )} - {showLabels && ( - - {result.label} ({getTypeLabel(result.type)}): - - )} - + {aggregationResults.map((result, index) => ( +
- {result.formattedValue} - -
- ))} -
+ {showIcons && ( + {getIcon(result.type)} + )} + {showLabels && ( + + {result.label} ({getTypeLabel(result.type)}): + + )} + + {result.formattedValue} + +
+ ))} +
+ ); } diff --git a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx index 4219d79d..fb19312c 100644 --- a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx @@ -27,7 +27,7 @@ interface AggregationWidgetConfigPanelProps { onChange: (config: Partial) => void; screenTableName?: string; // 화면 내 컴포넌트 목록 (컴포넌트 연결용) - screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string }>; + screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string; columnName?: string }>; } /** @@ -172,13 +172,14 @@ export function AggregationWidgetConfigPanel({ } try { - const response = await tableManagementApi.getColumns(sourceComp.tableName); - const cols = (response.data?.columns || response.data || []).map((col: any) => ({ + const response = await tableManagementApi.getColumnList(sourceComp.tableName); + const rawCols = response.data?.columns || (Array.isArray(response.data) ? response.data : []); + const cols = rawCols.map((col: any) => ({ columnName: col.column_name || col.columnName, label: col.column_label || col.columnLabel || col.display_name || col.column_name || col.columnName, })); - setSourceComponentColumnsCache(prev => ({ + setSourceComponentColumnsCache((prev) => ({ ...prev, [componentId]: cols, })); @@ -290,19 +291,20 @@ export function AggregationWidgetConfigPanel({ try { // 카테고리 API 호출 const result = await getCategoryValues(targetTableName, col.columnName, false); - if (result.success && Array.isArray(result.data)) { + if (result.success && "data" in result && Array.isArray(result.data)) { // 중복 제거 (valueCode 기준) const seenCodes = new Set(); const uniqueOptions: Array<{ value: string; label: string }> = []; for (const item of result.data) { - const code = item.valueCode || item.code || item.value || item.id; + const itemAny = item as any; + const code = item.valueCode || itemAny.code || itemAny.value || itemAny.id; if (!seenCodes.has(code)) { seenCodes.add(code); uniqueOptions.push({ value: code, // valueLabel이 실제 표시명 - label: item.valueLabel || item.valueName || item.name || item.label || item.displayName || code, + label: item.valueLabel || itemAny.valueName || itemAny.name || itemAny.label || itemAny.displayName || code, }); } } @@ -418,6 +420,52 @@ export function AggregationWidgetConfigPanel({ c.componentType === "table-list" ); + // 폼 필드로 사용 가능한 컴포넌트 (입력 위젯들만) + const formFieldComponents = useMemo(() => { + // 제외할 컴포넌트 타입 (표시 전용, 레이아웃, 컨테이너 등) + const excludeTypes = [ + "aggregation", "widget", "button", "label", "display", "table-list", + "repeat", "container", "layout", "section", "card", "tabs", "modal", + "flow", "rack", "map", "chart", "image", "file", "media" + ]; + + const filtered = screenComponents.filter((comp) => { + const type = comp.componentType?.toLowerCase() || ""; + + // 제외 대상인지 먼저 체크 + const isExcluded = excludeTypes.some(exclude => type.includes(exclude)); + if (isExcluded) return false; + + // 입력 가능한 컴포넌트 타입들 + const isInputType = ( + type.includes("input") || + type.includes("select") || + type.includes("date") || + type.includes("checkbox") || + type.includes("radio") || + type.includes("textarea") || + type.includes("number") || + // unified-input, unified-select, unified-date 등 (unified-repeater 등은 제외) + type === "unified-input" || + type === "unified-select" || + type === "unified-date" || + type === "unified-hierarchy" + ); + + // columnName이 있으면 입력 필드로 간주 (드래그로 배치된 필드) + const hasColumnName = !!comp.columnName; + + return isInputType || hasColumnName; + }); + + return filtered.map((comp) => ({ + id: comp.id, + label: comp.label || comp.columnName || comp.id, + columnName: comp.columnName || comp.id, + componentType: comp.componentType, + })); + }, [screenComponents]); + return (
집계 위젯 설정
@@ -444,7 +492,14 @@ export function AggregationWidgetConfigPanel({ variant={dataSourceType === "component" ? "default" : "outline"} size="sm" className="h-auto flex-col gap-1 py-2 text-xs" - onClick={() => onChange({ dataSourceType: "component" })} + onClick={() => { + // 컴포넌트 모드로 변경 시 화면의 메인 테이블로 자동 설정 + onChange({ + dataSourceType: "component", + tableName: screenTableName || config.tableName, + useCustomTable: false, + }); + }} > 컴포넌트 @@ -453,7 +508,14 @@ export function AggregationWidgetConfigPanel({ variant={dataSourceType === "selection" ? "default" : "outline"} size="sm" className="h-auto flex-col gap-1 py-2 text-xs" - onClick={() => onChange({ dataSourceType: "selection" })} + onClick={() => { + // 선택 데이터 모드로 변경 시 화면의 메인 테이블로 자동 설정 + onChange({ + dataSourceType: "selection", + tableName: screenTableName || config.tableName, + useCustomTable: false, + }); + }} > 선택 데이터 @@ -797,12 +859,32 @@ export function AggregationWidgetConfigPanel({ ) )} {filter.valueSourceType === "formField" && ( - updateFilter(filter.id, { formFieldName: e.target.value })} - placeholder="필드명 입력" - className="h-7 text-xs" - /> + formFieldComponents.length > 0 ? ( + + ) : ( +
+ 배치된 입력 필드가 없습니다 +
+ ) )} {filter.valueSourceType === "selection" && (
diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index d6f9e610..7e3f0107 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -370,13 +370,25 @@ export const DynamicComponentConfigPanel: React.FC = // 🆕 allComponents를 screenComponents 형태로 변환 (집계 위젯 등에서 사용) // Hooks 규칙: 조건부 return 전에 선언해야 함 const screenComponents = React.useMemo(() => { - if (!allComponents) return []; - return allComponents.map((comp: any) => ({ - id: comp.id, - componentType: comp.componentType || comp.type, - label: comp.label || comp.name || comp.id, - tableName: comp.componentConfig?.tableName || comp.tableName, - })); + if (!allComponents) { + console.log("[getComponentConfigPanel] allComponents is undefined or null"); + return []; + } + console.log("[getComponentConfigPanel] allComponents 변환 시작:", allComponents.length, "개"); + const result = allComponents.map((comp: any) => { + const columnName = comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName; + console.log(`[getComponentConfigPanel] comp: ${comp.id}, type: ${comp.componentType || comp.type}, columnName: ${columnName}`); + return { + id: comp.id, + componentType: comp.componentType || comp.type, + label: comp.label || comp.name || comp.id, + tableName: comp.componentConfig?.tableName || comp.tableName, + // 🆕 폼 필드 인식용 columnName 추가 + columnName, + }; + }); + console.log("[getComponentConfigPanel] screenComponents 변환 완료:", result); + return result; }, [allComponents]); if (loading) { diff --git a/frontend/lib/utils/webTypeMapping.ts b/frontend/lib/utils/webTypeMapping.ts index f4a380cf..d2a409f2 100644 --- a/frontend/lib/utils/webTypeMapping.ts +++ b/frontend/lib/utils/webTypeMapping.ts @@ -307,6 +307,12 @@ export function createUnifiedConfigFromColumn(column: { componentConfig.searchable = true; } + // select 타입인 경우: 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 + if (column.widgetType === "select" || column.inputType === "select") { + componentConfig.source = "select"; // DISTINCT 조회 모드 + componentConfig.searchable = true; + } + return { componentType: mapping.componentType, componentConfig,