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

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