diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 71a86a13..096273c9 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -324,6 +324,8 @@ export interface YardManagementConfig { // 사용자 커스텀 카드 설정 export interface CustomMetricConfig { + groupByMode?: boolean; // 그룹별 카드 생성 모드 (기본: false) + groupByDataSource?: ChartDataSource; // 그룹별 카드 전용 데이터 소스 (선택사항) metrics: Array<{ id: string; // 고유 ID field: string; // 집계할 컬럼명 diff --git a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx index 0a1dd39b..d87a40b3 100644 --- a/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/widgets/custom-metric/CustomMetricConfigSidebar.tsx @@ -37,8 +37,13 @@ export default function CustomMetricConfigSidebar({ const [dragOverIndex, setDragOverIndex] = useState(null); const [customTitle, setCustomTitle] = useState(element.customTitle || element.title || ""); const [showHeader, setShowHeader] = useState(element.showHeader !== false); + const [groupByMode, setGroupByMode] = useState(element.customMetricConfig?.groupByMode || false); + const [groupByDataSource, setGroupByDataSource] = useState( + element.customMetricConfig?.groupByDataSource, + ); + const [groupByQueryColumns, setGroupByQueryColumns] = useState([]); - // 쿼리 실행 결과 처리 + // 쿼리 실행 결과 처리 (일반 지표용) const handleQueryTest = (result: any) => { // QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } } if (result.success && result.data?.columns) { @@ -54,6 +59,17 @@ export default function CustomMetricConfigSidebar({ } }; + // 쿼리 실행 결과 처리 (그룹별 카드용) + const handleGroupByQueryTest = (result: any) => { + if (result.success && result.data?.columns) { + setGroupByQueryColumns(result.data.columns); + } else if (result.columns && Array.isArray(result.columns)) { + setGroupByQueryColumns(result.columns); + } else { + setGroupByQueryColumns([]); + } + }; + // 메트릭 추가 const addMetric = () => { const newMetric = { @@ -135,12 +151,20 @@ export default function CustomMetricConfigSidebar({ setQueryColumns([]); }; + // 그룹별 데이터 소스 업데이트 + const handleGroupByDataSourceUpdate = (updates: Partial) => { + const newDataSource = { ...groupByDataSource, ...updates } as ChartDataSource; + setGroupByDataSource(newDataSource); + }; + // 저장 const handleSave = () => { onApply({ customTitle: customTitle, showHeader: showHeader, customMetricConfig: { + groupByMode, + groupByDataSource: groupByMode ? groupByDataSource : undefined, metrics, }, }); @@ -250,17 +274,21 @@ export default function CustomMetricConfigSidebar({ )} - {/* 지표 설정 섹션 - 쿼리 실행 후에만 표시 */} - {queryColumns.length > 0 && ( -
-
-
지표
+ {/* 일반 지표 설정 (항상 표시) */} +
+
+
일반 지표
+ {queryColumns.length > 0 && ( -
+ )} +
+ {queryColumns.length === 0 ? ( +

먼저 쿼리를 실행하세요

+ ) : (
{metrics.length === 0 ? (

추가된 지표가 없습니다

@@ -410,6 +438,65 @@ export default function CustomMetricConfigSidebar({ )) )}
+ )} +
+ + {/* 그룹별 카드 생성 모드 (항상 표시) */} +
+
표시 모드
+
+
+
+ +

+ 쿼리 결과의 각 행을 개별 카드로 표시 +

+
+ +
+ {groupByMode && ( +
+

💡 사용 방법

+
    +
  • • 첫 번째 컬럼: 카드 제목
  • +
  • • 두 번째 컬럼: 카드 값
  • +
  • • 예: SELECT status, COUNT(*) FROM drivers GROUP BY status
  • +
  • 아래 별도 쿼리로 설정 (일반 지표와 독립적)
  • +
+
+ )} +
+
+ + {/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */} + {groupByMode && groupByDataSource && ( +
+
+ 그룹별 카드 쿼리 +
+ +
)}
diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index d6fe8086..d97ec05f 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -45,8 +45,10 @@ const colorMap = { export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) { const [metrics, setMetrics] = useState([]); + const [groupedCards, setGroupedCards] = useState>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const isGroupByMode = element?.customMetricConfig?.groupByMode || false; useEffect(() => { loadData(); @@ -61,136 +63,236 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) setLoading(true); setError(null); - // 데이터 소스 타입 확인 - const dataSourceType = element?.dataSource?.type; - - // 설정이 없으면 초기 상태로 반환 - if (!element?.customMetricConfig?.metrics) { - setMetrics([]); - setLoading(false); - return; + // 그룹별 카드 데이터 로드 + if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { + await loadGroupByData(); } - // Database 타입 - if (dataSourceType === "database") { - if (!element?.dataSource?.query) { - setMetrics([]); - setLoading(false); - return; - } - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: element.dataSource.query, - connectionType: element.dataSource.connectionType || "current", - connectionId: element.dataSource.connectionId, - }), - }); - - if (!response.ok) throw new Error("데이터 로딩 실패"); - - const result = await response.json(); - - if (result.success && result.data?.rows) { - const rows = result.data.rows; - - const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { - const value = calculateMetric(rows, metric.field, metric.aggregation); - return { - ...metric, - calculatedValue: value, - }; - }); - - setMetrics(calculatedMetrics); - } else { - throw new Error(result.message || "데이터 로드 실패"); - } - } - // API 타입 - else if (dataSourceType === "api") { - if (!element?.dataSource?.endpoint) { - setMetrics([]); - setLoading(false); - return; - } - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - method: element.dataSource.method || "GET", - url: element.dataSource.endpoint, - headers: element.dataSource.headers || {}, - body: element.dataSource.body, - authType: element.dataSource.authType, - authConfig: element.dataSource.authConfig, - }), - }); - - if (!response.ok) throw new Error("API 호출 실패"); - - const result = await response.json(); - - if (result.success && result.data) { - // API 응답 데이터 구조 확인 및 처리 - let rows: any[] = []; - - // result.data가 배열인 경우 - if (Array.isArray(result.data)) { - rows = result.data; - } - // result.data.results가 배열인 경우 (일반적인 API 응답 구조) - else if (result.data.results && Array.isArray(result.data.results)) { - rows = result.data.results; - } - // result.data.items가 배열인 경우 - else if (result.data.items && Array.isArray(result.data.items)) { - rows = result.data.items; - } - // result.data.data가 배열인 경우 - else if (result.data.data && Array.isArray(result.data.data)) { - rows = result.data.data; - } - // 그 외의 경우 단일 객체를 배열로 래핑 - else { - rows = [result.data]; - } - - const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { - const value = calculateMetric(rows, metric.field, metric.aggregation); - return { - ...metric, - calculatedValue: value, - }; - }); - - setMetrics(calculatedMetrics); - } else { - throw new Error("API 응답 형식 오류"); - } - } else { - setMetrics([]); - setLoading(false); + // 일반 지표 데이터 로드 + if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) { + await loadMetricsData(); } } catch (err) { - console.error("메트릭 로드 실패:", err); + console.error("데이터 로드 실패:", err); setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다"); } finally { setLoading(false); } }; + // 그룹별 카드 데이터 로드 + const loadGroupByData = async () => { + const groupByDS = element?.customMetricConfig?.groupByDataSource; + if (!groupByDS) return; + + const dataSourceType = groupByDS.type; + + // Database 타입 + if (dataSourceType === "database") { + if (!groupByDS.query) return; + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: groupByDS.query, + connectionType: groupByDS.connectionType || "current", + connectionId: groupByDS.connectionId, + }), + }); + + if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + const rows = result.data.rows; + if (rows.length > 0) { + const columns = result.data.columns || Object.keys(rows[0]); + const labelColumn = columns[0]; + const valueColumn = columns[1]; + + const cards = rows.map((row) => ({ + label: String(row[labelColumn] || ""), + value: parseFloat(row[valueColumn]) || 0, + })); + + setGroupedCards(cards); + } + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!groupByDS.endpoint) return; + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: groupByDS.method || "GET", + url: groupByDS.endpoint, + headers: groupByDS.headers || {}, + body: groupByDS.body, + authType: groupByDS.authType, + authConfig: groupByDS.authConfig, + }), + }); + + if (!response.ok) throw new Error("그룹별 카드 API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + let rows: any[] = []; + if (Array.isArray(result.data)) { + rows = result.data; + } else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } else { + rows = [result.data]; + } + + if (rows.length > 0) { + const columns = Object.keys(rows[0]); + const labelColumn = columns[0]; + const valueColumn = columns[1]; + + const cards = rows.map((row) => ({ + label: String(row[labelColumn] || ""), + value: parseFloat(row[valueColumn]) || 0, + })); + + setGroupedCards(cards); + } + } + } + }; + + // 일반 지표 데이터 로드 + const loadMetricsData = async () => { + const dataSourceType = element?.dataSource?.type; + + // Database 타입 + if (dataSourceType === "database") { + if (!element?.dataSource?.query) { + setMetrics([]); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: element.dataSource.connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + const rows = result.data.rows; + + const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { + const value = calculateMetric(rows, metric.field, metric.aggregation); + return { + ...metric, + calculatedValue: value, + }; + }); + + setMetrics(calculatedMetrics); + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!element?.dataSource?.endpoint) { + setMetrics([]); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: element.dataSource.method || "GET", + url: element.dataSource.endpoint, + headers: element.dataSource.headers || {}, + body: element.dataSource.body, + authType: element.dataSource.authType, + authConfig: element.dataSource.authConfig, + }), + }); + + if (!response.ok) throw new Error("API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + // API 응답 데이터 구조 확인 및 처리 + let rows: any[] = []; + + // result.data가 배열인 경우 + if (Array.isArray(result.data)) { + rows = result.data; + } + // result.data.results가 배열인 경우 (일반적인 API 응답 구조) + else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } + // result.data.items가 배열인 경우 + else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } + // result.data.data가 배열인 경우 + else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } + // 그 외의 경우 단일 객체를 배열로 래핑 + else { + rows = [result.data]; + } + + const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { + const value = calculateMetric(rows, metric.field, metric.aggregation); + return { + ...metric, + calculatedValue: value, + }; + }); + + setMetrics(calculatedMetrics); + } else { + throw new Error("API 응답 형식 오류"); + } + } + }; + if (loading) { return (
@@ -218,12 +320,26 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) ); } - // 데이터 소스가 없거나 설정이 없는 경우 - const hasDataSource = + // 데이터 소스 체크 + const hasMetricsDataSource = (element?.dataSource?.type === "database" && element?.dataSource?.query) || (element?.dataSource?.type === "api" && element?.dataSource?.endpoint); - if (!hasDataSource || !element?.customMetricConfig?.metrics || metrics.length === 0) { + const hasGroupByDataSource = + isGroupByMode && + element?.customMetricConfig?.groupByDataSource && + ((element.customMetricConfig.groupByDataSource.type === "database" && + element.customMetricConfig.groupByDataSource.query) || + (element.customMetricConfig.groupByDataSource.type === "api" && + element.customMetricConfig.groupByDataSource.endpoint)); + + const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0; + + // 둘 다 없으면 빈 화면 표시 + const shouldShowEmpty = + (!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource); + + if (shouldShowEmpty) { return (
@@ -235,11 +351,21 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • • 사용자 정의 단위 설정 가능
  • +
  • 그룹별 카드 생성 모드로 간편하게 사용 가능
  • ⚙️ 설정 방법

    -

    SQL 쿼리를 입력하고 지표를 추가하세요

    +

    + {isGroupByMode + ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" + : "SQL 쿼리를 입력하고 지표를 추가하세요"} +

    + {isGroupByMode && ( +

    + 💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값 +

    + )}
    @@ -251,6 +377,23 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {/* 스크롤 가능한 콘텐츠 영역 */}
    + {/* 그룹별 카드 (활성화 시) */} + {isGroupByMode && + groupedCards.map((card, index) => { + // 색상 순환 (6가지 색상) + const colorKeys = Object.keys(colorMap) as Array; + const colorKey = colorKeys[index % colorKeys.length]; + const colors = colorMap[colorKey]; + + return ( +
    +
    {card.label}
    +
    {card.value.toLocaleString()}
    +
    + ); + })} + + {/* 일반 지표 카드 (항상 표시) */} {metrics.map((metric) => { const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; const formattedValue = metric.calculatedValue.toFixed(metric.decimals);