feat: DISTINCT 값 조회 API 추가 및 라우터 설정

- 테이블 컬럼의 DISTINCT 값을 조회하는 API를 추가하였습니다. 이 API는 특정 테이블과 컬럼에서 DISTINCT 값을 반환하여 선택박스 옵션으로 사용할 수 있도록 합니다.
- API 호출 시 멀티테넌시를 고려하여 회사 코드에 따라 필터링을 적용하였습니다.
- 관련된 라우터 설정을 추가하여 API 접근을 가능하게 하였습니다.
- 프론트엔드에서 DISTINCT 값을 조회할 수 있도록 UnifiedSelect 컴포넌트를 업데이트하였습니다.
This commit is contained in:
kjs 2026-01-27 23:02:03 +09:00
parent cc742b27f1
commit a06f2eb52c
9 changed files with 624 additions and 93 deletions

View File

@ -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

View File

@ -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);

View File

@ -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>

View File

@ -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>
);
})}

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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) {

View File

@ -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,