313 lines
9.2 KiB
TypeScript
313 lines
9.2 KiB
TypeScript
"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;
|
|
|