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

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