feature/v2-unified-renewal #379

Merged
kjs merged 145 commits from feature/v2-unified-renewal into main 2026-02-03 12:11:19 +09:00
4 changed files with 867 additions and 154 deletions
Showing only changes of commit d45443521d - Show all commits

View File

@ -1,16 +1,99 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
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";
interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
externalData?: any[];
// 폼 데이터 (필터 조건용)
formData?: Record<string, any>;
// 선택된 행 데이터
selectedRows?: any[];
}
/**
*
*/
function applyFilters(
data: any[],
filters: FilterCondition[],
filterLogic: "AND" | "OR",
formData: Record<string, any>,
selectedRows: any[]
): any[] {
if (!filters || filters.length === 0) {
return data;
}
const enabledFilters = filters.filter((f) => f.enabled && f.columnName);
if (enabledFilters.length === 0) {
return data;
}
return data.filter((row) => {
const results = enabledFilters.map((filter) => {
const rowValue = row[filter.columnName];
// 값 소스에 따라 비교 값 결정
let compareValue: any;
switch (filter.valueSourceType) {
case "static":
compareValue = filter.staticValue;
break;
case "formField":
compareValue = formData?.[filter.formFieldName || ""];
break;
case "selection":
// 선택된 행에서 값 가져오기 (첫 번째 선택 행 기준)
compareValue = selectedRows?.[0]?.[filter.sourceColumnName || ""];
break;
case "urlParam":
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
compareValue = urlParams.get(filter.urlParamName || "");
}
break;
}
// 연산자에 따른 비교
switch (filter.operator) {
case "eq":
return rowValue == compareValue;
case "neq":
return rowValue != compareValue;
case "gt":
return Number(rowValue) > Number(compareValue);
case "gte":
return Number(rowValue) >= Number(compareValue);
case "lt":
return Number(rowValue) < Number(compareValue);
case "lte":
return Number(rowValue) <= Number(compareValue);
case "like":
return String(rowValue || "").toLowerCase().includes(String(compareValue || "").toLowerCase());
case "in":
const inValues = String(compareValue || "").split(",").map((v) => v.trim());
return inValues.includes(String(rowValue));
case "isNull":
return rowValue === null || rowValue === undefined || rowValue === "";
case "isNotNull":
return rowValue !== null && rowValue !== undefined && rowValue !== "";
default:
return true;
}
});
// AND/OR 논리 적용
return filterLogic === "AND" ? results.every((r) => r) : results.some((r) => r);
});
}
/**
@ -22,12 +105,14 @@ export function AggregationWidgetComponent({
isDesignMode = false,
config: propsConfig,
externalData,
formData = {},
selectedRows = [],
}: AggregationWidgetComponentProps) {
// 다국어 지원
const { getText } = useScreenMultiLang();
const componentConfig: AggregationWidgetConfig = {
dataSourceType: "manual",
dataSourceType: "table",
items: [],
layout: "horizontal",
showLabels: true,
@ -51,6 +136,11 @@ export function AggregationWidgetComponent({
const {
dataSourceType,
dataSourceComponentId,
tableName,
customTableName,
useCustomTable,
filters,
filterLogic = "AND",
items,
layout,
showLabels,
@ -64,26 +154,169 @@ export function AggregationWidgetComponent({
valueFontSize,
labelColor,
valueColor,
autoRefresh,
refreshInterval,
refreshOnFormChange,
} = componentConfig;
// 데이터 상태
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 사용할 테이블명 결정
const effectiveTableName = useCustomTable && customTableName ? customTableName : tableName;
// Refs로 최신 값 참조 (의존성 배열에서 제외하여 무한 루프 방지)
const filtersRef = React.useRef(filters);
const formDataRef = React.useRef(formData);
const selectedRowsRef = React.useRef(selectedRows);
// 값이 변경될 때마다 ref 업데이트
React.useEffect(() => {
filtersRef.current = filters;
}, [filters]);
React.useEffect(() => {
formDataRef.current = formData;
}, [formData]);
React.useEffect(() => {
selectedRowsRef.current = selectedRows;
}, [selectedRows]);
// 테이블에서 데이터 조회 (dataSourceType === "table"일 때)
const fetchTableData = useCallback(async () => {
if (isDesignMode || !effectiveTableName || dataSourceType !== "table") {
return;
}
try {
setLoading(true);
setError(null);
// 테이블 데이터 조회 API 호출
// 멀티테넌시: company_code 자동 필터링 활성화
const response = await apiClient.post(`/table-management/tables/${effectiveTableName}/data`, {
size: 10000, // 집계용이므로 충분한 데이터 조회
page: 1,
autoFilter: {
enabled: true,
filterColumn: "company_code",
userField: "companyCode",
},
});
// 응답 구조: { success: true, data: { data: [...], total: ... } }
const raw = response.data?.data || response.data;
const rows = raw?.data || raw || [];
if (Array.isArray(rows)) {
// 필터 적용
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
} catch (err: any) {
console.error("집계 위젯 데이터 조회 오류:", err);
setError(err.message || "데이터 조회 실패");
} finally {
setLoading(false);
}
}, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]);
// 테이블 데이터 조회 (초기 로드)
useEffect(() => {
if (dataSourceType === "table" && effectiveTableName && !isDesignMode) {
fetchTableData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceType, effectiveTableName, isDesignMode]);
// 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때)
const formDataKey = JSON.stringify(formData);
useEffect(() => {
if (dataSourceType === "table" && refreshOnFormChange && !isDesignMode && effectiveTableName) {
// 초기 로드 후에만 재조회
const timeoutId = setTimeout(() => {
fetchTableData();
}, 100);
return () => clearTimeout(timeoutId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formDataKey, refreshOnFormChange]);
// 자동 새로고침
useEffect(() => {
if (dataSourceType === "table" && autoRefresh && refreshInterval && !isDesignMode) {
const interval = setInterval(fetchTableData, refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]);
// 선택된 행 집계 (dataSourceType === "selection"일 때)
const selectedRowsKey = JSON.stringify(selectedRows);
useEffect(() => {
if (dataSourceType === "selection" && Array.isArray(selectedRows) && selectedRows.length > 0) {
setData(selectedRows);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceType, selectedRowsKey]);
// 외부 데이터가 있으면 사용
const externalDataKey = externalData ? JSON.stringify(externalData.slice(0, 5)) : null; // 첫 5개만 비교
useEffect(() => {
if (externalData && Array.isArray(externalData)) {
setData(externalData);
// 필터 적용
const filteredData = applyFilters(
externalData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
}, [externalData]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalDataKey, filterLogic]);
// 컴포넌트 데이터 변경 이벤트 리스닝
// 컴포넌트 데이터 변경 이벤트 리스닝 (dataSourceType === "component"일 때)
useEffect(() => {
if (!dataSourceComponentId || isDesignMode) return;
if (dataSourceType !== "component" || !dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
setData(eventData);
// 필터 적용
const filteredData = applyFilters(
eventData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 선택 변경 이벤트 (체크박스 선택 등)
const handleSelectionChange = (event: CustomEvent) => {
const { componentId, selectedData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(selectedData)) {
// 선택된 데이터만 집계
const filteredData = applyFilters(
selectedData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
@ -91,12 +324,15 @@ export function AggregationWidgetComponent({
window.addEventListener("repeaterDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트
window.addEventListener("tableListDataChange" as any, handleDataChange);
// 선택 변경 이벤트
window.addEventListener("selectionChange" as any, handleSelectionChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
window.removeEventListener("selectionChange" as any, handleSelectionChange);
};
}, [dataSourceComponentId, isDesignMode]);
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);
// 집계 계산
const aggregationResults = useMemo((): AggregationResult[] => {
@ -108,10 +344,12 @@ export function AggregationWidgetComponent({
const values = data
.map((row) => {
const val = row[item.columnName];
return typeof val === "number" ? val : parseFloat(val) || 0;
const parsed = typeof val === "number" ? val : parseFloat(val) || 0;
return parsed;
})
.filter((v) => !isNaN(v));
let value: number = 0;
switch (item.type) {
@ -192,6 +430,20 @@ export function AggregationWidgetComponent({
}
};
// 데이터 소스 타입 라벨
const getDataSourceLabel = (type: DataSourceType) => {
switch (type) {
case "table":
return "테이블";
case "component":
return "컴포넌트";
case "selection":
return "선택 데이터";
default:
return "수동";
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
const previewItems: AggregationResult[] =
@ -210,46 +462,80 @@ 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,
}}
>
{previewItems.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="space-y-1">
{/* 디자인 모드에서 데이터 소스 표시 */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>[{getDataSourceLabel(dataSourceType)}]</span>
{dataSourceType === "table" && effectiveTableName && (
<span>{effectiveTableName}</span>
)}
{dataSourceType === "component" && dataSourceComponentId && (
<span>{dataSourceComponentId}</span>
)}
{(filters || []).length > 0 && (
<span className="text-blue-500"> {filters?.length}</span>
)}
</div>
<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,
}}
>
{previewItems.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>
))}
{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>
</div>
);
}
// 로딩 상태
if (loading) {
return (
<div className="flex items-center justify-center rounded-md border bg-slate-50 p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex items-center justify-center rounded-md border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
);
}

View File

@ -14,9 +14,10 @@ import {
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Plus, Trash2, GripVertical, Database, Table2, ChevronsUpDown, Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, GripVertical, Database, Table2, ChevronsUpDown, Check, Filter, Link2, MousePointer } from "lucide-react";
import { cn } from "@/lib/utils";
import { AggregationWidgetConfig, AggregationItem, AggregationType } from "./types";
import { AggregationWidgetConfig, AggregationItem, AggregationType, DataSourceType, FilterCondition, FilterOperator, FilterValueSourceType } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
@ -24,15 +25,40 @@ interface AggregationWidgetConfigPanelProps {
config: AggregationWidgetConfig;
onChange: (config: Partial<AggregationWidgetConfig>) => void;
screenTableName?: string;
// 화면 내 컴포넌트 목록 (컴포넌트 연결용)
screenComponents?: Array<{ id: string; componentType: string; label?: string }>;
}
/**
*
*/
// 연산자 라벨
const OPERATOR_LABELS: Record<FilterOperator, string> = {
eq: "같음 (=)",
neq: "같지 않음 (!=)",
gt: "보다 큼 (>)",
gte: "크거나 같음 (>=)",
lt: "보다 작음 (<)",
lte: "작거나 같음 (<=)",
like: "포함",
in: "목록에 포함",
isNull: "NULL",
isNotNull: "NOT NULL",
};
// 값 소스 타입 라벨
const VALUE_SOURCE_LABELS: Record<FilterValueSourceType, string> = {
static: "고정 값",
formField: "폼 필드",
selection: "선택된 행",
urlParam: "URL 파라미터",
};
export function AggregationWidgetConfigPanel({
config,
onChange,
screenTableName,
screenComponents = [],
}: AggregationWidgetConfigPanelProps) {
const [columns, setColumns] = useState<Array<{ columnName: string; label?: string; dataType?: string; inputType?: string; webType?: string }>>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
@ -40,6 +66,9 @@ export function AggregationWidgetConfigPanel({
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// 데이터 소스 타입 (기본값: table)
const dataSourceType = config.dataSourceType || "table";
// 실제 사용할 테이블 이름 계산
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
@ -156,116 +185,449 @@ export function AggregationWidgetConfigPanel({
);
});
// 필터 추가
const addFilter = () => {
const newFilter: FilterCondition = {
id: `filter-${Date.now()}`,
columnName: "",
operator: "eq",
valueSourceType: "static",
staticValue: "",
enabled: true,
};
onChange({
filters: [...(config.filters || []), newFilter],
});
};
// 필터 삭제
const removeFilter = (id: string) => {
onChange({
filters: (config.filters || []).filter((f) => f.id !== id),
});
};
// 필터 업데이트
const updateFilter = (id: string, updates: Partial<FilterCondition>) => {
onChange({
filters: (config.filters || []).map((f) =>
f.id === id ? { ...f, ...updates } : f
),
});
};
// 연결 가능한 컴포넌트 (리피터, 테이블리스트)
const linkableComponents = screenComponents.filter(
(c) => c.componentType === "v2-unified-repeater" ||
c.componentType === "v2-table-list" ||
c.componentType === "unified-repeater" ||
c.componentType === "table-list"
);
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 테이블 설정 (컴포넌트 개발 가이드 준수) */}
{/* 데이터 소스 타입 선택 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
{/* 현재 선택된 테이블 표시 (카드 형태) */}
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
<Database className="h-4 w-4 text-blue-500" />
<div className="flex-1">
<div className="text-xs font-medium">
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
</div>
<div className="text-[10px] text-muted-foreground">
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<Button
variant={dataSourceType === "table" ? "default" : "outline"}
size="sm"
className="h-auto flex-col gap-1 py-2 text-xs"
onClick={() => onChange({ dataSourceType: "table" })}
>
<Database className="h-4 w-4" />
<span></span>
</Button>
<Button
variant={dataSourceType === "component" ? "default" : "outline"}
size="sm"
className="h-auto flex-col gap-1 py-2 text-xs"
onClick={() => onChange({ dataSourceType: "component" })}
>
<Link2 className="h-4 w-4" />
<span></span>
</Button>
<Button
variant={dataSourceType === "selection" ? "default" : "outline"}
size="sm"
className="h-auto flex-col gap-1 py-2 text-xs"
onClick={() => onChange({ dataSourceType: "selection" })}
>
<MousePointer className="h-4 w-4" />
<span> </span>
</Button>
</div>
{/* 테이블 선택 Combobox */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
{/* 테이블 선택 (table 타입일 때) */}
{dataSourceType === "table" && (
<div className="space-y-2 mt-3">
<Label className="text-xs"> </Label>
{/* 현재 선택된 테이블 표시 */}
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
<Database className="h-4 w-4 text-blue-500" />
<div className="flex-1">
<div className="text-xs font-medium">
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
</div>
<div className="text-[10px] text-muted-foreground">
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
</div>
</div>
</div>
{/* 테이블 선택 Combobox */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
...
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
{/* 그룹 1: 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
key={`default-${screenTableName}`}
value={screenTableName}
onSelect={() => {
onChange({
useCustomTable: false,
customTableName: undefined,
tableName: screenTableName,
items: [],
filters: [],
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!config.useCustomTable ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3 w-3 text-blue-500" />
{screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 그룹 2: 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{availableTables
.filter((table) => table.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
onChange({
useCustomTable: true,
customTableName: table.tableName,
tableName: table.tableName,
items: [],
filters: [],
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 컴포넌트 연결 (component 타입일 때) */}
{dataSourceType === "component" && (
<div className="space-y-2 mt-3">
<Label className="text-xs"> </Label>
<Select
value={config.dataSourceComponentId || ""}
onValueChange={(value) => onChange({ dataSourceComponentId: value })}
>
...
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
{/* 그룹 1: 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
key={`default-${screenTableName}`}
value={screenTableName}
onSelect={() => {
onChange({
useCustomTable: false,
customTableName: undefined,
tableName: screenTableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!config.useCustomTable ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3 w-3 text-blue-500" />
{screenTableName}
</CommandItem>
</CommandGroup>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컴포넌트 선택..." />
</SelectTrigger>
<SelectContent>
{linkableComponents.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
<br/>
<span className="text-[10px]">( )</span>
</div>
) : (
linkableComponents.map((comp) => (
<SelectItem key={comp.id} value={comp.id}>
{comp.label || comp.id} ({comp.componentType})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
{/* 그룹 2: 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{availableTables
.filter((table) => table.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
onChange({
useCustomTable: true,
customTableName: table.tableName,
tableName: table.tableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 선택 데이터 설명 (selection 타입일 때) */}
{dataSourceType === "selection" && (
<div className="space-y-2 mt-3">
<div className="rounded-md border bg-blue-50 p-3 text-xs text-blue-700">
<p className="font-medium mb-1"> </p>
<p className="text-[10px]">
() .
.
</p>
</div>
{/* 테이블 선택 (어느 테이블의 선택 데이터인지) */}
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
<Database className="h-4 w-4 text-blue-500" />
<div className="flex-1">
<div className="text-xs font-medium">
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
</div>
</div>
</div>
</div>
)}
</div>
{/* 필터 조건 (table 또는 selection 타입일 때) */}
{(dataSourceType === "table" || dataSourceType === "selection") && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold flex items-center gap-1">
<Filter className="h-3 w-3" />
</h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<Button variant="outline" size="sm" onClick={addFilter} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<hr className="border-border" />
{/* 필터 결합 방식 */}
{(config.filters || []).length > 1 && (
<div className="flex items-center gap-2">
<Label className="text-xs"> :</Label>
<Select
value={config.filterLogic || "AND"}
onValueChange={(value) => onChange({ filterLogic: value as "AND" | "OR" })}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND ( )</SelectItem>
<SelectItem value="OR">OR ( )</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 필터 목록 */}
{(config.filters || []).length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground">
. .
</div>
) : (
<div className="space-y-2">
{(config.filters || []).map((filter, index) => (
<div
key={filter.id}
className={cn(
"rounded-md border p-3 space-y-2",
filter.enabled ? "bg-slate-50" : "bg-slate-100 opacity-60"
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={filter.enabled}
onCheckedChange={(checked) => updateFilter(filter.id, { enabled: checked as boolean })}
/>
<span className="text-xs font-medium"> {index + 1}</span>
{filter.columnName && (
<Badge variant="secondary" className="text-[10px]">
{filter.columnName}
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFilter(filter.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{/* 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => updateFilter(filter.id, { columnName: value })}
disabled={loadingColumns}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연산자 선택 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={filter.operator}
onValueChange={(value) => updateFilter(filter.id, { operator: value as FilterOperator })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(OPERATOR_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 소스 타입 */}
{filter.operator !== "isNull" && filter.operator !== "isNotNull" && (
<>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={filter.valueSourceType}
onValueChange={(value) => updateFilter(filter.id, { valueSourceType: value as FilterValueSourceType })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VALUE_SOURCE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 입력 (소스 타입에 따라) */}
<div className="space-y-1">
<Label className="text-[10px]">
{filter.valueSourceType === "static" && "값"}
{filter.valueSourceType === "formField" && "폼 필드명"}
{filter.valueSourceType === "selection" && "소스 컬럼"}
{filter.valueSourceType === "urlParam" && "파라미터명"}
</Label>
{filter.valueSourceType === "static" && (
<Input
value={String(filter.staticValue || "")}
onChange={(e) => updateFilter(filter.id, { staticValue: e.target.value })}
placeholder="값 입력"
className="h-7 text-xs"
/>
)}
{filter.valueSourceType === "formField" && (
<Input
value={filter.formFieldName || ""}
onChange={(e) => updateFilter(filter.id, { formFieldName: e.target.value })}
placeholder="필드명 입력"
className="h-7 text-xs"
/>
)}
{filter.valueSourceType === "selection" && (
<Select
value={filter.sourceColumnName || ""}
onValueChange={(value) => updateFilter(filter.id, { sourceColumnName: value })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{filter.valueSourceType === "urlParam" && (
<Input
value={filter.urlParamName || ""}
onChange={(e) => updateFilter(filter.id, { urlParamName: e.target.value })}
placeholder="파라미터명"
className="h-7 text-xs"
/>
)}
</div>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 레이아웃 설정 */}
<div className="space-y-3">
<div>

View File

@ -14,13 +14,15 @@ export const V2AggregationWidgetDefinition = createComponentDefinition({
id: "v2-aggregation-widget",
name: "집계 위젯",
nameEng: "Aggregation Widget",
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯",
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯 (필터링 지원)",
category: ComponentCategory.DISPLAY,
webType: "text",
component: AggregationWidgetWrapper,
defaultConfig: {
dataSourceType: "manual",
dataSourceType: "table", // 기본값: 테이블에서 직접 조회
items: [],
filters: [], // 필터 조건
filterLogic: "AND",
layout: "horizontal",
showLabels: true,
showIcons: true,
@ -28,15 +30,26 @@ export const V2AggregationWidgetDefinition = createComponentDefinition({
backgroundColor: "#f8fafc",
borderRadius: "6px",
padding: "12px",
autoRefresh: false,
refreshOnFormChange: true, // 폼 변경 시 자동 새로고침
} as Partial<AggregationWidgetConfig>,
defaultSize: { width: 400, height: 60 },
configPanel: AggregationWidgetConfigPanel,
icon: "Calculator",
tags: ["집계", "합계", "평균", "개수", "통계", "데이터"],
version: "1.0.0",
tags: ["집계", "합계", "평균", "개수", "통계", "데이터", "필터"],
version: "1.1.0",
author: "개발팀",
});
// 타입 내보내기
export type { AggregationWidgetConfig, AggregationItem, AggregationType, AggregationResult } from "./types";
export type {
AggregationWidgetConfig,
AggregationItem,
AggregationType,
AggregationResult,
DataSourceType,
FilterCondition,
FilterOperator,
FilterValueSourceType,
} from "./types";

View File

@ -5,6 +5,50 @@ import { ComponentConfig } from "@/types/component";
*/
export type AggregationType = "sum" | "avg" | "count" | "max" | "min";
/**
*
*/
export type DataSourceType = "table" | "component" | "selection";
/**
*
*/
export type FilterOperator =
| "eq" // 같음 (=)
| "neq" // 같지 않음 (!=)
| "gt" // 보다 큼 (>)
| "gte" // 크거나 같음 (>=)
| "lt" // 보다 작음 (<)
| "lte" // 작거나 같음 (<=)
| "like" // 포함 (LIKE)
| "in" // 목록에 포함 (IN)
| "isNull" // NULL 여부
| "isNotNull"; // NOT NULL 여부
/**
*
*/
export type FilterValueSourceType =
| "static" // 고정 값
| "formField" // 폼 필드에서 가져오기
| "selection" // 선택된 행에서 가져오기
| "urlParam"; // URL 파라미터에서 가져오기
/**
*
*/
export interface FilterCondition {
id: string;
columnName: string; // 필터 적용할 컬럼
operator: FilterOperator; // 연산자
valueSourceType: FilterValueSourceType; // 값 소스 타입
staticValue?: string | number | boolean; // 고정 값 (valueSourceType이 static일 때)
formFieldName?: string; // 폼 필드명 (valueSourceType이 formField일 때)
sourceColumnName?: string; // 소스 컬럼명 (valueSourceType이 selection일 때)
urlParamName?: string; // URL 파라미터명 (valueSourceType이 urlParam일 때)
enabled: boolean; // 필터 활성화 여부
}
/**
*
*/
@ -26,14 +70,18 @@ export interface AggregationItem {
*/
export interface AggregationWidgetConfig extends ComponentConfig {
// 데이터 소스 설정
dataSourceType: "repeater" | "tableList" | "manual"; // 데이터 소스 타입
dataSourceComponentId?: string; // 연결할 컴포넌트 ID (repeater 또는 tableList)
dataSourceType: DataSourceType; // 데이터 소스 타입
dataSourceComponentId?: string; // 연결할 컴포넌트 ID (component 타입일 때)
// 컴포넌트별 테이블 설정 (개발 가이드 준수)
tableName?: string; // 사용할 테이블명
customTableName?: string; // 커스텀 테이블명
useCustomTable?: boolean; // true: customTableName 사용
// 필터 조건 (table 타입일 때 사용)
filters?: FilterCondition[];
filterLogic?: "AND" | "OR"; // 필터 조건 결합 방식 (기본: AND)
// 집계 항목들
items: AggregationItem[];
@ -52,6 +100,11 @@ export interface AggregationWidgetConfig extends ComponentConfig {
valueFontSize?: string;
labelColor?: string;
valueColor?: string;
// 자동 새로고침 설정
autoRefresh?: boolean; // 자동 새로고침 활성화
refreshInterval?: number; // 새로고침 간격 (초)
refreshOnFormChange?: boolean; // 폼 데이터 변경 시 새로고침
}
/**
@ -64,4 +117,3 @@ export interface AggregationResult {
formattedValue: string;
type: AggregationType;
}