feat: DISTINCT 값 조회 API 추가 및 라우터 설정
- 테이블 컬럼의 DISTINCT 값을 조회하는 API를 추가하였습니다. 이 API는 특정 테이블과 컬럼에서 DISTINCT 값을 반환하여 선택박스 옵션으로 사용할 수 있도록 합니다. - API 호출 시 멀티테넌시를 고려하여 회사 코드에 따라 필터링을 적용하였습니다. - 관련된 라우터 설정을 추가하여 API 접근을 가능하게 하였습니다. - 프론트엔드에서 DISTINCT 값을 조회할 수 있도록 UnifiedSelect 컴포넌트를 업데이트하였습니다.
This commit is contained in:
parent
cc742b27f1
commit
a06f2eb52c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -303,6 +303,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
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,
|
||||
}))}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
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<string>(getInitialTab());
|
||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
||||
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
|
||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
||||
|
||||
// screenId 기반 화면 로드 상태
|
||||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 컴포넌트 탭 목록 변경 시 동기화
|
||||
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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2 text-muted-foreground">화면을 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 발생
|
||||
if (screenErrors[tab.id]) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5">
|
||||
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 레이아웃이 로드된 경우
|
||||
const loadedComponents = screenLayouts[tab.id];
|
||||
if (loadedComponents && loadedComponents.length > 0) {
|
||||
return renderScreenComponents(loadedComponents);
|
||||
}
|
||||
|
||||
// 아직 로드되지 않은 경우
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
|
||||
</p>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
|
||||
if (inlineComponents.length > 0) {
|
||||
return renderInlineComponents(tab, inlineComponents);
|
||||
}
|
||||
|
||||
// 3. 둘 다 없는 경우
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto"
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
>
|
||||
{components.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: comp.position?.x || 0,
|
||||
top: comp.position?.y || 0,
|
||||
width: comp.size?.width || "auto",
|
||||
height: comp.size?.height || "auto",
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewerDynamic
|
||||
component={comp}
|
||||
allComponents={components}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 인라인 컴포넌트 렌더링 (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)}
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -618,6 +618,19 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
// 선택된 행 데이터
|
||||
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<AggregationWidgetConfig>(() => ({
|
||||
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<string, any> = {};
|
||||
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<string, any> = {};
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border bg-slate-50 p-3",
|
||||
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
gap: gap || "12px",
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
borderRadius: borderRadius || undefined,
|
||||
padding: padding || undefined,
|
||||
fontSize: fontSize || undefined,
|
||||
}}
|
||||
<V2ErrorBoundary
|
||||
componentId={component?.id || "aggregation-widget"}
|
||||
componentType="v2-aggregation-widget"
|
||||
>
|
||||
{aggregationResults.map((result, index) => (
|
||||
<div
|
||||
key={result.id || index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
||||
layout === "vertical" ? "w-full justify-between" : ""
|
||||
)}
|
||||
>
|
||||
{showIcons && (
|
||||
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
style={{ fontSize: labelFontSize, color: labelColor }}
|
||||
>
|
||||
{result.label} ({getTypeLabel(result.type)}):
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: valueFontSize, color: valueColor }}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border bg-slate-50 p-3",
|
||||
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
gap: gap || "12px",
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
borderRadius: borderRadius || undefined,
|
||||
padding: padding || undefined,
|
||||
fontSize: fontSize || undefined,
|
||||
}}
|
||||
>
|
||||
{aggregationResults.map((result, index) => (
|
||||
<div
|
||||
key={result.id || index}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
|
||||
layout === "vertical" ? "w-full justify-between" : ""
|
||||
)}
|
||||
>
|
||||
{result.formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showIcons && (
|
||||
<span className="text-muted-foreground">{getIcon(result.type)}</span>
|
||||
)}
|
||||
{showLabels && (
|
||||
<span
|
||||
className="text-muted-foreground text-xs"
|
||||
style={{ fontSize: labelFontSize, color: labelColor }}
|
||||
>
|
||||
{result.label} ({getTypeLabel(result.type)}):
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: valueFontSize, color: valueColor }}
|
||||
>
|
||||
{result.formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ interface AggregationWidgetConfigPanelProps {
|
|||
onChange: (config: Partial<AggregationWidgetConfig>) => 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<string>();
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">집계 위젯 설정</div>
|
||||
|
|
@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span>컴포넌트</span>
|
||||
|
|
@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MousePointer className="h-4 w-4" />
|
||||
<span>선택 데이터</span>
|
||||
|
|
@ -797,12 +859,32 @@ export function AggregationWidgetConfigPanel({
|
|||
)
|
||||
)}
|
||||
{filter.valueSourceType === "formField" && (
|
||||
<Input
|
||||
value={filter.formFieldName || ""}
|
||||
onChange={(e) => updateFilter(filter.id, { formFieldName: e.target.value })}
|
||||
placeholder="필드명 입력"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
formFieldComponents.length > 0 ? (
|
||||
<Select
|
||||
value={filter.formFieldName || ""}
|
||||
onValueChange={(value) => updateFilter(filter.id, { formFieldName: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="폼 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formFieldComponents.map((field) => (
|
||||
<SelectItem key={field.id} value={field.columnName}>
|
||||
{field.label}
|
||||
{field.columnName !== field.label && (
|
||||
<span className="ml-1 text-muted-foreground text-[10px]">
|
||||
({field.columnName})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground h-7 px-2 border rounded-md bg-slate-50">
|
||||
<span>배치된 입력 필드가 없습니다</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{filter.valueSourceType === "selection" && (
|
||||
<div className="space-y-2 col-span-2">
|
||||
|
|
|
|||
|
|
@ -370,13 +370,25 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
// 🆕 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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue