다국어 지원 및 테이블 설정 현황 문서를 업데이트하여 현재 사용 가능한 17개 컴포넌트의 기능 현황을 반영했습니다. 또한, 집계 위젯(aggregation-widget) 관련 기능을 추가하고, UI에서 다국어 지원을 위한 라벨 수집 및 매핑 로직을 개선하여 사용자 경험을 향상시켰습니다.

This commit is contained in:
kjs 2026-01-16 11:02:27 +09:00
parent f160ba2a1b
commit 6c920b21a4
10 changed files with 1049 additions and 16 deletions

View File

@ -1,7 +1,7 @@
# 컴포넌트 기능 현황 # 컴포넌트 기능 현황
> 작성일: 2026-01-15 > 작성일: 2026-01-16
> 현재 사용 가능한 16개 컴포넌트의 다국어 지원 및 테이블 설정 기능 현황 > 현재 사용 가능한 17개 컴포넌트의 다국어 지원 및 테이블 설정 기능 현황
--- ---
@ -9,8 +9,8 @@
| 기능 | 적용 완료 | 미적용 | 해당없음 | | 기능 | 적용 완료 | 미적용 | 해당없음 |
| -------------------------- | --------- | ------ | -------- | | -------------------------- | --------- | ------ | -------- |
| **다국어 지원** | 3개 | 9개 | 4개 | | **다국어 지원** | 4개 | 9개 | 4개 |
| **컴포넌트별 테이블 설정** | 6개 | 4개 | 6개 | | **컴포넌트별 테이블 설정** | 7개 | 4개 | 6개 |
--- ---
@ -56,14 +56,15 @@
--- ---
### 유틸리티 (Utility) - 4 ### 유틸리티 (Utility) - 5
| 컴포넌트 | 다국어 지원 | 테이블 설정 | 비고 | | 컴포넌트 | 다국어 지원 | 테이블 설정 | 비고 |
| ---------------------- | :---------: | :---------: | ------------------------------------------- | | ---------------------- | :---------: | :---------: | --------------------------------------------------------- |
| **코드 채번 규칙** | ❌ 미적용 | 해당없음 | 채번 규칙 관리 전용 | | **집계 위젯** | ✅ 적용 | ✅ 적용 | `customTableName` 지원, 항목별 `labelLangKey` 다국어 지원 |
| **렉 구조 설정** | ❌ 미적용 | 해당없음 | 창고 렉 설정 전용 | | **코드 채번 규칙** | ❌ 미적용 | 해당없음 | 채번 규칙 관리 전용 |
| **출발지/도착지 선택** | ❌ 미적용 | ⚠️ 부분 | `customTableName` 지원하나 Combobox UI 없음 | | **렉 구조 설정** | ❌ 미적용 | 해당없음 | 창고 렉 설정 전용 |
| **검색 필터** | ❌ 미적용 | ⚠️ 부분 | `screenTableName` 자동 감지 | | **출발지/도착지 선택** | ❌ 미적용 | ⚠️ 부분 | `customTableName` 지원하나 Combobox UI 없음 |
| **검색 필터** | ❌ 미적용 | ⚠️ 부분 | `screenTableName` 자동 감지 |
--- ---
@ -73,11 +74,12 @@
다국어 지원이란 컴포넌트의 라벨, 플레이스홀더 등 텍스트 속성에 다국어 키를 연결하여 언어별로 다른 텍스트를 표시하는 기능입니다. 다국어 지원이란 컴포넌트의 라벨, 플레이스홀더 등 텍스트 속성에 다국어 키를 연결하여 언어별로 다른 텍스트를 표시하는 기능입니다.
**적용 완료 (3개)** **적용 완료 (4개)**
- `table-list`: 컬럼 라벨 다국어 지원 - `table-list`: 컬럼 라벨 다국어 지원
- `button-primary`: 버튼 텍스트 다국어 지원 - `button-primary`: 버튼 텍스트 다국어 지원
- `split-panel-layout`: 패널 제목 다국어 지원 - `split-panel-layout`: 패널 제목 다국어 지원
- `aggregation-widget`: 집계 항목별 표시 라벨 다국어 지원
**해당없음 (4개)** **해당없음 (4개)**
@ -99,7 +101,7 @@
컴포넌트별 테이블 설정이란 화면의 메인 테이블과 별개로 컴포넌트가 자체적으로 사용할 테이블을 지정할 수 있는 기능입니다. 컴포넌트별 테이블 설정이란 화면의 메인 테이블과 별개로 컴포넌트가 자체적으로 사용할 테이블을 지정할 수 있는 기능입니다.
**완전 적용 (5개)** **완전 적용 (6개)**
| 컴포넌트 | 적용 방식 | | 컴포넌트 | 적용 방식 |
| -------------------- | --------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------- |
@ -108,6 +110,7 @@
| `unified-list` | `TableListConfigPanel` 래핑하여 동일 기능 제공 | | `unified-list` | `TableListConfigPanel` 래핑하여 동일 기능 제공 |
| `card-display` | Combobox UI로 테이블 선택, `customTableName`, `useCustomTable` 지원 | | `card-display` | Combobox UI로 테이블 선택, `customTableName`, `useCustomTable` 지원 |
| `split-panel-layout` | 좌우 패널 각각 Combobox UI로 테이블 선택, 다국어 지원 | | `split-panel-layout` | 좌우 패널 각각 Combobox UI로 테이블 선택, 다국어 지원 |
| `aggregation-widget` | Combobox UI로 테이블 선택, `customTableName`, `useCustomTable` 지원 |
**부분 적용 (4개)** **부분 적용 (4개)**

View File

@ -491,7 +491,7 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => { const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => {
const anyComp = comp as any; const anyComp = comp as any;
const config = anyComp.componentConfig; const config = anyComp.componentConfig || anyComp.config;
const compType = anyComp.componentType || anyComp.type; const compType = anyComp.componentType || anyComp.type;
const compLabel = anyComp.label || anyComp.title || compType; const compLabel = anyComp.label || anyComp.title || compType;
@ -728,6 +728,23 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
}); });
} }
// 11. 집계 위젯 (aggregation-widget)
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) {
config.items.forEach((item: any, index: number) => {
if (item.columnLabel && typeof item.columnLabel === "string") {
addLabel(
`${comp.id}_agg_${item.id || index}`,
item.columnLabel,
"label",
compType,
compLabel,
item.labelLangKeyId,
item.labelLangKey
);
}
});
}
// 자식 컴포넌트 재귀 탐색 // 자식 컴포넌트 재귀 탐색
if (anyComp.children && Array.isArray(anyComp.children)) { if (anyComp.children && Array.isArray(anyComp.children)) {
anyComp.children.forEach((child: ComponentData) => { anyComp.children.forEach((child: ComponentData) => {

View File

@ -99,6 +99,14 @@ export const ScreenMultiLangProvider: React.FC<ScreenMultiLangProviderProps> = (
} }
}); });
} }
// 집계 위젯 (aggregation-widget) items의 labelLangKey 수집
if ((comp as any).componentType === "aggregation-widget" && config?.items) {
config.items.forEach((item: any) => {
if (item.labelLangKey) {
keys.push(item.labelLangKey);
}
});
}
// 자식 컴포넌트 재귀 처리 // 자식 컴포넌트 재귀 처리
if ((comp as any).children) { if ((comp as any).children) {
collectLangKeys((comp as any).children); collectLangKeys((comp as any).children);

View File

@ -0,0 +1,312 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
externalData?: any[];
}
/**
*
*
*/
export function AggregationWidgetComponent({
component,
isDesignMode = false,
config: propsConfig,
externalData,
}: AggregationWidgetComponentProps) {
// 다국어 지원
const { getText } = useScreenMultiLang();
const componentConfig: AggregationWidgetConfig = {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
...propsConfig,
...component?.config,
};
// 다국어 라벨 가져오기
const getItemLabel = (item: AggregationItem): string => {
if (item.labelLangKey) {
const translated = getText(item.labelLangKey);
if (translated && translated !== item.labelLangKey) {
return translated;
}
}
return item.columnLabel || item.columnName || "컬럼";
};
const {
dataSourceType,
dataSourceComponentId,
items,
layout,
showLabels,
showIcons,
gap,
backgroundColor,
borderRadius,
padding,
fontSize,
labelFontSize,
valueFontSize,
labelColor,
valueColor,
} = componentConfig;
// 데이터 상태
const [data, setData] = useState<any[]>([]);
// 외부 데이터가 있으면 사용
useEffect(() => {
if (externalData && Array.isArray(externalData)) {
setData(externalData);
}
}, [externalData]);
// 컴포넌트 데이터 변경 이벤트 리스닝
useEffect(() => {
if (!dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
setData(eventData);
}
};
// 리피터 데이터 변경 이벤트
window.addEventListener("repeaterDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트
window.addEventListener("tableListDataChange" as any, handleDataChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
};
}, [dataSourceComponentId, isDesignMode]);
// 집계 계산
const aggregationResults = useMemo((): AggregationResult[] => {
if (!items || items.length === 0) {
return [];
}
return items.map((item) => {
const values = data
.map((row) => {
const val = row[item.columnName];
return typeof val === "number" ? val : parseFloat(val) || 0;
})
.filter((v) => !isNaN(v));
let value: number = 0;
switch (item.type) {
case "sum":
value = values.reduce((acc, v) => acc + v, 0);
break;
case "avg":
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
break;
case "count":
value = data.length;
break;
case "max":
value = values.length > 0 ? Math.max(...values) : 0;
break;
case "min":
value = values.length > 0 ? Math.min(...values) : 0;
break;
}
// 포맷팅
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
}
if (item.prefix) {
formattedValue = `${item.prefix}${formattedValue}`;
}
if (item.suffix) {
formattedValue = `${formattedValue}${item.suffix}`;
}
return {
id: item.id,
label: getItemLabel(item),
value,
formattedValue,
type: item.type,
};
});
}, [data, items, getText]);
// 집계 타입에 따른 아이콘
const getIcon = (type: AggregationType) => {
switch (type) {
case "sum":
return <Calculator className="h-4 w-4" />;
case "avg":
return <TrendingUp className="h-4 w-4" />;
case "count":
return <Hash className="h-4 w-4" />;
case "max":
return <ArrowUp className="h-4 w-4" />;
case "min":
return <ArrowDown className="h-4 w-4" />;
}
};
// 집계 타입 라벨
const getTypeLabel = (type: AggregationType) => {
switch (type) {
case "sum":
return "합계";
case "avg":
return "평균";
case "count":
return "개수";
case "max":
return "최대";
case "min":
return "최소";
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
const previewItems: AggregationResult[] =
items.length > 0
? items.map((item) => ({
id: item.id,
label: getItemLabel(item),
value: 0,
formattedValue: item.prefix ? `${item.prefix}0${item.suffix || ""}` : `0${item.suffix || ""}`,
type: item.type,
}))
: [
{ id: "1", label: "총 수량", value: 150, formattedValue: "150", type: "sum" },
{ id: "2", label: "총 금액", value: 1500000, formattedValue: "₩1,500,000", type: "sum" },
{ id: "3", label: "건수", value: 5, formattedValue: "5건", type: "count" },
];
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 }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
// 실제 렌더링
if (aggregationResults.length === 0) {
return (
<div className="flex items-center justify-center rounded-md border border-dashed bg-slate-50 p-4 text-sm text-muted-foreground">
</div>
);
}
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,
}}
>
{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 }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
export const AggregationWidgetWrapper = AggregationWidgetComponent;

View File

@ -0,0 +1,533 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} 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 { cn } from "@/lib/utils";
import { AggregationWidgetConfig, AggregationItem, AggregationType } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
interface AggregationWidgetConfigPanelProps {
config: AggregationWidgetConfig;
onChange: (config: Partial<AggregationWidgetConfig>) => void;
screenTableName?: string;
}
/**
*
*/
export function AggregationWidgetConfigPanel({
config,
onChange,
screenTableName,
}: AggregationWidgetConfigPanelProps) {
const [columns, setColumns] = useState<Array<{ columnName: string; label?: string; dataType?: string }>>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// 실제 사용할 테이블 이름 계산
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
// 화면 테이블명 자동 설정 (초기 한 번만)
useEffect(() => {
if (screenTableName && !config.tableName && !config.customTableName) {
onChange({ tableName: screenTableName });
}
}, [screenTableName, config.tableName, config.customTableName, onChange]);
// 전체 테이블 목록 로드
useEffect(() => {
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await tableTypeApi.getTables();
setAvailableTables(
response.map((table: any) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
}))
);
} catch (error) {
console.error("테이블 목록 가져오기 실패:", error);
} finally {
setLoadingTables(false);
}
};
fetchTables();
}, []);
// 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setColumns(
result.data.columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
label: col.displayName || col.columnLabel || col.column_label || col.label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type,
}))
);
} else {
setColumns([]);
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName]);
// 집계 항목 추가
const addItem = () => {
const newItem: AggregationItem = {
id: `agg-${Date.now()}`,
columnName: "",
columnLabel: "",
type: "sum",
format: "number",
decimalPlaces: 0,
};
onChange({
items: [...(config.items || []), newItem],
});
};
// 집계 항목 삭제
const removeItem = (id: string) => {
onChange({
items: (config.items || []).filter((item) => item.id !== id),
});
};
// 집계 항목 업데이트
const updateItem = (id: string, updates: Partial<AggregationItem>) => {
onChange({
items: (config.items || []).map((item) =>
item.id === id ? { ...item, ...updates } : item
),
});
};
// 숫자형 컬럼만 필터링 (count 제외)
const numericColumns = columns.filter(
(col) =>
col.dataType?.toLowerCase().includes("int") ||
col.dataType?.toLowerCase().includes("numeric") ||
col.dataType?.toLowerCase().includes("decimal") ||
col.dataType?.toLowerCase().includes("float") ||
col.dataType?.toLowerCase().includes("double")
);
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>
</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>
{/* 테이블 선택 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: [], // 테이블 변경 시 집계 항목 초기화
});
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: [], // 테이블 변경 시 집계 항목 초기화
});
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>
{/* 레이아웃 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.layout || "horizontal"}
onValueChange={(value) => onChange({ layout: value as "horizontal" | "vertical" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> </SelectItem>
<SelectItem value="vertical"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.gap || "16px"}
onChange={(e) => onChange({ gap: e.target.value })}
placeholder="16px"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="showLabels"
checked={config.showLabels ?? true}
onCheckedChange={(checked) => onChange({ showLabels: checked as boolean })}
/>
<Label htmlFor="showLabels" className="text-xs">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="showIcons"
checked={config.showIcons ?? true}
onCheckedChange={(checked) => onChange({ showIcons: checked as boolean })}
/>
<Label htmlFor="showIcons" className="text-xs">
</Label>
</div>
</div>
</div>
{/* 집계 항목 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<hr className="border-border" />
{(config.items || []).length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-3">
{(config.items || []).map((item, index) => (
<div
key={item.id}
className="rounded-md border bg-slate-50 p-3 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<span className="text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(item.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={item.columnName}
onValueChange={(value) => {
const col = columns.find((c) => c.columnName === value);
updateItem(item.id, {
columnName: value,
columnLabel: col?.label || value,
});
}}
disabled={loadingColumns || columns.length === 0}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={
loadingColumns
? "로딩 중..."
: columns.length === 0
? "테이블을 선택하세요"
: "컬럼 선택"
} />
</SelectTrigger>
<SelectContent>
{(item.type === "count" ? columns : numericColumns).length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
{item.type === "count"
? "컬럼이 없습니다"
: "숫자형 컬럼이 없습니다"}
</div>
) : (
(item.type === "count" ? columns : numericColumns).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={item.type}
onValueChange={(value) => updateItem(item.id, { type: value as AggregationType })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시 라벨 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={item.columnLabel || ""}
onChange={(e) => updateItem(item.id, { columnLabel: e.target.value })}
placeholder="표시될 라벨"
className="h-7 text-xs"
/>
</div>
{/* 표시 형식 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={item.format || "number"}
onValueChange={(value) =>
updateItem(item.id, { format: value as "number" | "currency" | "percent" })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="percent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접두사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.prefix || ""}
onChange={(e) => updateItem(item.id, { prefix: e.target.value })}
placeholder="예: ₩"
className="h-7 text-xs"
/>
</div>
{/* 접미사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.suffix || ""}
onChange={(e) => updateItem(item.id, { suffix: e.target.value })}
placeholder="예: 원, 개"
className="h-7 text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 스타일 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="color"
value={config.backgroundColor || "#f8fafc"}
onChange={(e) => onChange({ backgroundColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.borderRadius || "6px"}
onChange={(e) => onChange({ borderRadius: e.target.value })}
placeholder="6px"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.labelColor || "#64748b"}
onChange={(e) => onChange({ labelColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.valueColor || "#0f172a"}
onChange={(e) => onChange({ valueColor: e.target.value })}
className="h-8"
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { AggregationWidgetDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(AggregationWidgetDefinition);
}
export {};

View File

@ -0,0 +1,42 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { AggregationWidgetWrapper } from "./AggregationWidgetComponent";
import { AggregationWidgetConfigPanel } from "./AggregationWidgetConfigPanel";
import type { AggregationWidgetConfig } from "./types";
/**
* AggregationWidget
* (, , )
*/
export const AggregationWidgetDefinition = createComponentDefinition({
id: "aggregation-widget",
name: "집계 위젯",
nameEng: "Aggregation Widget",
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯",
category: ComponentCategory.DISPLAY,
webType: "text",
component: AggregationWidgetWrapper,
defaultConfig: {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
backgroundColor: "#f8fafc",
borderRadius: "6px",
padding: "12px",
} as Partial<AggregationWidgetConfig>,
defaultSize: { width: 400, height: 60 },
configPanel: AggregationWidgetConfigPanel,
icon: "Calculator",
tags: ["집계", "합계", "평균", "개수", "통계", "데이터"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { AggregationWidgetConfig, AggregationItem, AggregationType, AggregationResult } from "./types";

View File

@ -0,0 +1,67 @@
import { ComponentConfig } from "@/types/component";
/**
*
*/
export type AggregationType = "sum" | "avg" | "count" | "max" | "min";
/**
*
*/
export interface AggregationItem {
id: string;
columnName: string; // 집계할 컬럼
columnLabel?: string; // 표시 라벨
labelLangKeyId?: number; // 다국어 키 ID
labelLangKey?: string; // 다국어 키
type: AggregationType; // 집계 타입
format?: "number" | "currency" | "percent"; // 표시 형식
decimalPlaces?: number; // 소수점 자릿수
prefix?: string; // 접두사 (예: "₩")
suffix?: string; // 접미사 (예: "원", "개")
}
/**
*
*/
export interface AggregationWidgetConfig extends ComponentConfig {
// 데이터 소스 설정
dataSourceType: "repeater" | "tableList" | "manual"; // 데이터 소스 타입
dataSourceComponentId?: string; // 연결할 컴포넌트 ID (repeater 또는 tableList)
// 컴포넌트별 테이블 설정 (개발 가이드 준수)
tableName?: string; // 사용할 테이블명
customTableName?: string; // 커스텀 테이블명
useCustomTable?: boolean; // true: customTableName 사용
// 집계 항목들
items: AggregationItem[];
// 레이아웃 설정
layout: "horizontal" | "vertical"; // 배치 방향
showLabels: boolean; // 라벨 표시 여부
showIcons: boolean; // 아이콘 표시 여부
gap?: string; // 항목 간 간격
// 스타일 설정
backgroundColor?: string;
borderRadius?: string;
padding?: string;
fontSize?: string;
labelFontSize?: string;
valueFontSize?: string;
labelColor?: string;
valueColor?: string;
}
/**
*
*/
export interface AggregationResult {
id: string;
label: string;
value: number | string;
formattedValue: string;
type: AggregationType;
}

View File

@ -94,6 +94,9 @@ import "./unified-repeater/UnifiedRepeaterRenderer"; // 인라인/모달/버튼
// 🆕 피벗 그리드 컴포넌트 // 🆕 피벗 그리드 컴포넌트
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운) import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
// 🆕 집계 위젯 컴포넌트
import "./aggregation-widget/AggregationWidgetRenderer"; // 데이터 집계 (합계, 평균, 개수 등)
/** /**
* *
*/ */

View File

@ -89,7 +89,7 @@ export function extractMultilangLabels(
const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => { const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => {
const anyComp = comp as any; const anyComp = comp as any;
const config = anyComp.componentConfig; const config = anyComp.componentConfig || anyComp.config;
const compType = anyComp.componentType || anyComp.type; const compType = anyComp.componentType || anyComp.type;
const compLabel = anyComp.label || anyComp.title || compType; const compLabel = anyComp.label || anyComp.title || compType;
@ -326,6 +326,23 @@ export function extractMultilangLabels(
}); });
} }
// 11. 집계 위젯 (aggregation-widget)
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) {
config.items.forEach((item: any, index: number) => {
if (item.columnLabel && typeof item.columnLabel === "string") {
addLabel(
`${comp.id}_agg_${item.id || index}`,
item.columnLabel,
"label",
compType,
compLabel,
item.labelLangKeyId,
item.labelLangKey
);
}
});
}
// 자식 컴포넌트 재귀 탐색 // 자식 컴포넌트 재귀 탐색
if (anyComp.children && Array.isArray(anyComp.children)) { if (anyComp.children && Array.isArray(anyComp.children)) {
anyComp.children.forEach((child: ComponentData) => { anyComp.children.forEach((child: ComponentData) => {
@ -401,7 +418,7 @@ export function applyMultilangMappings(
const updateComponent = (comp: ComponentData): ComponentData => { const updateComponent = (comp: ComponentData): ComponentData => {
const anyComp = comp as any; const anyComp = comp as any;
const config = anyComp.componentConfig; const config = anyComp.componentConfig || anyComp.config;
let updated = { ...comp } as any; let updated = { ...comp } as any;
// 기본 컴포넌트 라벨 매핑 확인 // 기본 컴포넌트 라벨 매핑 확인
@ -591,6 +608,25 @@ export function applyMultilangMappings(
}; };
} }
// 집계 위젯 (aggregation-widget) 매핑
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) {
const updatedItems = config.items.map((item: any, index: number) => {
const itemMapping = mappingMap.get(`${comp.id}_agg_${item.id || index}`);
if (itemMapping) {
return {
...item,
labelLangKeyId: itemMapping.keyId,
labelLangKey: itemMapping.langKey,
};
}
return item;
});
updated.componentConfig = {
...updated.componentConfig,
items: updatedItems,
};
}
// 자식 컴포넌트 재귀 처리 // 자식 컴포넌트 재귀 처리
if (anyComp.children && Array.isArray(anyComp.children)) { if (anyComp.children && Array.isArray(anyComp.children)) {
updated.children = anyComp.children.map((child: ComponentData) => updateComponent(child)); updated.children = anyComp.children.map((child: ComponentData) => updateComponent(child));