feat: DISTINCT 값 조회 API 추가 및 라우터 설정
- 테이블 컬럼의 DISTINCT 값을 조회하는 API를 추가하였습니다. 이 API는 특정 테이블과 컬럼에서 DISTINCT 값을 반환하여 선택박스 옵션으로 사용할 수 있도록 합니다. - API 호출 시 멀티테넌시를 고려하여 회사 코드에 따라 필터링을 적용하였습니다. - 관련된 라우터 설정을 추가하여 API 접근을 가능하게 하였습니다. - 프론트엔드에서 DISTINCT 값을 조회할 수 있도록 UnifiedSelect 컴포넌트를 업데이트하였습니다.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user