V2 컴포넌트 규칙 추가 및 기존 컴포넌트 자동 등록 개선: 화면 컴포넌트 개발 가이드에 V2 컴포넌트 사용 규칙을 명시하고, ComponentsPanel에서 수동으로 추가하던 table-list 컴포넌트를 자동 등록으로 변경하여 관리 효율성을 높였습니다. 또한, V2 컴포넌트 목록과 수정/개발 시 규칙을 추가하여 일관된 개발 환경을 조성하였습니다.
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user