diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 07cdd61c..247b1ab8 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -449,7 +449,10 @@ export class BatchService { // 기존 배치 설정 확인 (회사 권한 체크 포함) const existing = await this.getBatchConfigById(id, userCompanyCode); if (!existing.success) { - return existing as ApiResponse; + return { + success: false, + message: existing.message, + }; } const existingConfig = await queryOne( diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 63146b24..b776e963 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -389,9 +389,9 @@ export function CanvasElement({ let newX = resizeStart.elementX; let newY = resizeStart.elementY; - // 최소 크기 설정: 달력은 2x3, 나머지는 2x2 - const minWidthCells = 2; - const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2; + // 최소 크기 설정: 모든 위젯 1x1 + const minWidthCells = 1; + const minHeightCells = 1; const minWidth = cellSize * minWidthCells; const minHeight = cellSize * minHeightCells; @@ -757,7 +757,7 @@ export function CanvasElement({
{/* 헤더 */} -
+
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */} {element.type === "chart" && ( @@ -779,7 +779,7 @@ export function CanvasElement({ }} > e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > @@ -808,7 +808,7 @@ export function CanvasElement({ )} {/* 제목 */} {!element.type || element.type !== "chart" ? ( - {element.customTitle || element.title} + {element.customTitle || element.title} ) : null}
@@ -816,18 +816,18 @@ export function CanvasElement({
{/* 내용 */} -
+
{element.type === "chart" ? ( // 차트 렌더링
@@ -843,7 +843,7 @@ export function CanvasElement({ element={element} data={chartData || undefined} width={element.size.width} - height={element.size.height - 45} + height={element.size.height - 32} /> )}
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index e9ab7df8..934bcbb3 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -277,6 +277,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D setElements((prev) => [...prev, newElement]); setElementCounter((prev) => prev + 1); setSelectedElement(newElement.id); + + // 새 요소 생성 시 자동으로 설정 사이드바 열기 + setSidebarElement(newElement); + setSidebarOpen(true); }, [elementCounter, canvasConfig], ); diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 94efd190..3cd5afbe 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -242,12 +242,12 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende // D3 차트 렌더링 const actualWidth = width !== undefined ? width : containerWidth; - // 원형 차트는 더 큰 크기가 필요 (최소 400px) + // 최소 크기 제약 완화 (1x1 위젯 지원) const isCircularChart = element.subtype === "pie" || element.subtype === "donut"; - const minWidth = isCircularChart ? 400 : 200; - const finalWidth = Math.max(actualWidth - 20, minWidth); - // 원형 차트는 범례 공간을 위해 더 많은 여백 필요 - const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300); + const minWidth = 35; // 최소 너비 35px + const finalWidth = Math.max(actualWidth - 4, minWidth); + // 최소 높이도 35px로 설정 + const finalHeight = Math.max(height - (isCircularChart ? 10 : 4), 35); console.log("🎨 ChartRenderer:", { elementSubtype: element.subtype, @@ -263,7 +263,7 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende }); return ( -
+
- +
+ {/* 시계판 배경 */} @@ -70,7 +70,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP y={y} textAnchor="middle" dominantBaseline="middle" - fontSize="20" + fontSize="16" fontWeight="bold" fill={colors.number} > @@ -86,7 +86,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP x2={100 + 40 * Math.cos((hourAngle * Math.PI) / 180)} y2={100 + 40 * Math.sin((hourAngle * Math.PI) / 180)} stroke={colors.hourHand} - strokeWidth="6" + strokeWidth="5" strokeLinecap="round" /> @@ -97,7 +97,7 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP x2={100 + 60 * Math.cos((minuteAngle * Math.PI) / 180)} y2={100 + 60 * Math.sin((minuteAngle * Math.PI) / 180)} stroke={colors.minuteHand} - strokeWidth="4" + strokeWidth="3" strokeLinecap="round" /> @@ -108,18 +108,18 @@ export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockP x2={100 + 75 * Math.cos((secondAngle * Math.PI) / 180)} y2={100 + 75 * Math.sin((secondAngle * Math.PI) / 180)} stroke={colors.secondHand} - strokeWidth="2" + strokeWidth="1.5" strokeLinecap="round" /> {/* 중심점 */} - - + + {/* 타임존 표시 */} {timezoneLabel && ( -
+
{timezoneLabel}
)} diff --git a/frontend/components/admin/dashboard/widgets/DigitalClock.tsx b/frontend/components/admin/dashboard/widgets/DigitalClock.tsx index eb8b9cba..5ed506c5 100644 --- a/frontend/components/admin/dashboard/widgets/DigitalClock.tsx +++ b/frontend/components/admin/dashboard/widgets/DigitalClock.tsx @@ -56,21 +56,21 @@ export function DigitalClock({ return (
{/* 날짜 표시 (compact 모드에서는 숨김) */} {!compact && showDate && dateString && ( -
{dateString}
+
{dateString}
)} {/* 시간 표시 */} -
+
{timeString}
{/* 타임존 표시 */} -
+
{timezoneLabel}
diff --git a/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx index 62db5cef..cd72b044 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidgetConfigSidebar.tsx @@ -94,24 +94,19 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L const handleQueryTest = useCallback((result: QueryResult) => { setQueryResult(result); - // 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 추가 (기존 컬럼은 유지) - setListConfig((prev) => { - const existingFields = prev.columns.map((col) => col.field); - const newColumns = result.columns - .filter((col) => !existingFields.includes(col)) - .map((col, idx) => ({ - id: `col_${Date.now()}_${idx}`, - field: col, - label: col, - visible: true, - align: "left" as const, - })); + // 쿼리 실행 시마다 컬럼 설정 초기화 (새로운 쿼리 결과로 덮어쓰기) + const newColumns = result.columns.map((col, idx) => ({ + id: `col_${Date.now()}_${idx}`, + field: col, + label: col, + visible: true, + align: "left" as const, + })); - return { - ...prev, - columns: [...prev.columns, ...newColumns], - }; - }); + setListConfig((prev) => ({ + ...prev, + columns: newColumns, + })); }, []); // 컬럼 설정 변경 diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index b5af2630..7e9cddec 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -31,6 +31,7 @@ interface Yard3DCanvasProps { onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void; gridSize?: number; // 그리드 크기 (기본값: 5) onCollisionDetected?: () => void; // 충돌 감지 시 콜백 + focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID } // 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) @@ -467,6 +468,75 @@ function MaterialBox({ } // 3D 씬 컴포넌트 +// 카메라 포커스 컨트롤러 +function CameraFocusController({ + focusOnPlacementId, + placements, + orbitControlsRef, +}: { + focusOnPlacementId?: number | null; + placements: YardPlacement[]; + orbitControlsRef: React.RefObject; +}) { + const { camera } = useThree(); + + useEffect(() => { + console.log("🎥 CameraFocusController triggered"); + console.log(" - focusOnPlacementId:", focusOnPlacementId); + console.log(" - orbitControlsRef.current:", orbitControlsRef.current); + console.log(" - placements count:", placements.length); + + if (focusOnPlacementId && orbitControlsRef.current) { + const targetPlacement = placements.find((p) => p.id === focusOnPlacementId); + console.log(" - targetPlacement:", targetPlacement); + + if (targetPlacement) { + console.log("✅ Starting camera animation to:", targetPlacement.material_name || targetPlacement.id); + + const controls = orbitControlsRef.current; + const targetPosition = new THREE.Vector3( + targetPlacement.position_x, + targetPlacement.position_y, + targetPlacement.position_z, + ); + + // 카메라 위치 계산 (요소 위에서 약간 비스듬히) + const cameraOffset = new THREE.Vector3(15, 15, 15); + const newCameraPosition = targetPosition.clone().add(cameraOffset); + + // 부드러운 애니메이션으로 카메라 이동 + const startPos = camera.position.clone(); + const startTarget = controls.target.clone(); + const duration = 1000; // 1초 + const startTime = Date.now(); + + const animate = () => { + const elapsed = Date.now() - startTime; + const progress = Math.min(elapsed / duration, 1); + + // easeInOutCubic 이징 함수 + const eased = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2; + + // 카메라 위치 보간 + camera.position.lerpVectors(startPos, newCameraPosition, eased); + + // 카메라 타겟 보간 + controls.target.lerpVectors(startTarget, targetPosition, eased); + controls.update(); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + animate(); + } + } + }, [focusOnPlacementId, placements, camera, orbitControlsRef]); + + return null; +} + function Scene({ placements, selectedPlacementId, @@ -474,12 +544,20 @@ function Scene({ onPlacementDrag, gridSize = 5, onCollisionDetected, + focusOnPlacementId, }: Yard3DCanvasProps) { const [isDraggingAny, setIsDraggingAny] = useState(false); const orbitControlsRef = useRef(null); return ( <> + {/* 카메라 포커스 컨트롤러 */} + + {/* 조명 */} @@ -551,6 +629,7 @@ export default function Yard3DCanvas({ onPlacementDrag, gridSize = 5, onCollisionDetected, + focusOnPlacementId, }: Yard3DCanvasProps) { const handleCanvasClick = (e: any) => { // Canvas의 빈 공간을 클릭했을 때만 선택 해제 @@ -577,6 +656,7 @@ export default function Yard3DCanvas({ onPlacementDrag={onPlacementDrag} gridSize={gridSize} onCollisionDetected={onCollisionDetected} + focusOnPlacementId={focusOnPlacementId} /> diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index a9fea2f3..726239f0 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -38,6 +38,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { const [placements, setPlacements] = useState([]); const [originalPlacements, setOriginalPlacements] = useState([]); // 원본 데이터 보관 const [selectedPlacement, setSelectedPlacement] = useState(null); + const [focusPlacementId, setFocusPlacementId] = useState(null); // 카메라 포커스할 요소 ID const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [showConfigPanel, setShowConfigPanel] = useState(false); @@ -203,9 +204,30 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { }; // 요소 선택 (3D 캔버스 또는 목록에서) - const handleSelectPlacement = (placement: YardPlacement) => { + const handleSelectPlacement = (placement: YardPlacement | null) => { + console.log("📍 handleSelectPlacement called with:", placement); + + if (!placement) { + // 빈 공간 클릭 시 선택 해제 + console.log(" → Deselecting (null placement)"); + setSelectedPlacement(null); + setShowConfigPanel(false); + setFocusPlacementId(null); + return; + } + + console.log(" → Selecting placement:", placement.id, placement.material_name); setSelectedPlacement(placement); setShowConfigPanel(false); // 선택 시에는 설정 패널 닫기 + + console.log(" → Setting focusPlacementId to:", placement.id); + setFocusPlacementId(placement.id); // 카메라 포커스 + + // 카메라 애니메이션 완료 후 focusPlacementId 초기화 (재클릭 시 다시 포커스 가능) + setTimeout(() => { + console.log(" → Clearing focusPlacementId"); + setFocusPlacementId(null); + }, 1100); // 애니메이션 시간(1000ms)보다 약간 길게 }; // 설정 버튼 클릭 @@ -500,8 +522,9 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { handleSelectPlacement(placement as YardPlacement)} + onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement | null)} onPlacementDrag={handlePlacementDrag} + focusOnPlacementId={focusPlacementId} onCollisionDetected={() => { toast({ title: "배치 불가", diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 1a5dd15b..2d9ad481 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -400,8 +400,8 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi style={{ minHeight: "300px" }} > {element.showHeader !== false && ( -
-

{element.customTitle || element.title}

+
+

{element.customTitle || element.title}

)} -
+
{!isMounted ? (
@@ -462,8 +462,8 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi }} > {element.showHeader !== false && ( -
-

{element.customTitle || element.title}

+
+

{element.customTitle || element.title}

)} -
+
{!isMounted ? (
@@ -496,7 +496,7 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi element={element} data={data} width={undefined} - height={element.showHeader !== false ? element.size.height - 50 : element.size.height} + height={element.showHeader !== false ? element.size.height - 32 : element.size.height} /> ) : ( renderWidget(element) diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index 26aafa3b..9c23c714 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -1,25 +1,425 @@ -/* - * ⚠️ DEPRECATED - 이 위젯은 더 이상 사용되지 않습니다. - * - * 이 파일은 2025-10-28에 주석 처리되었습니다. - * 새로운 버전: CustomMetricTestWidget.tsx (subtype: custom-metric-v2) - * - * 변경 이유: - * - 다중 데이터 소스 지원 (REST API + Database 혼합) - * - 컬럼 매핑 기능 추가 - * - 자동 새로고침 간격 설정 가능 - * - 상세 정보 모달 (클릭 시 원본 데이터 표시) - * - Group By Mode 지원 - * - * 이 파일은 복구를 위해 보관 중이며, - * 향후 문제 발생 시 참고용으로 사용될 수 있습니다. - * - * 롤백 방법: - * 1. 이 파일의 주석 제거 - * 2. types.ts에서 "custom-metric" 활성화 - * 3. "custom-metric-v2" 주석 처리 - */ +"use client"; -// "use client"; -// -// ... (전체 코드 주석 처리됨) +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; +import { getApiUrl } from "@/lib/utils/apiUrl"; + +interface CustomMetricWidgetProps { + element?: DashboardElement; +} + +// 집계 함수 실행 +const calculateMetric = (rows: any[], field: string, aggregation: string): number => { + if (rows.length === 0) return 0; + + switch (aggregation) { + case "count": + return rows.length; + case "sum": { + return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0); + } + case "avg": { + const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0); + return rows.length > 0 ? sum / rows.length : 0; + } + case "min": { + return Math.min(...rows.map((row) => parseFloat(row[field]) || 0)); + } + case "max": { + return Math.max(...rows.map((row) => parseFloat(row[field]) || 0)); + } + default: + return 0; + } +}; + +// 색상 스타일 매핑 +const colorMap = { + indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" }, + green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" }, + purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" }, + gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" }, +}; + +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(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [element]); + + const loadData = async () => { + try { + setLoading(true); + setError(null); + + // 그룹별 카드 데이터 로드 + if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { + await loadGroupByData(); + } + + // 일반 지표 데이터 로드 + if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) { + await loadMetricsData(); + } + } catch (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 as any).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: any) => ({ + 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 as any).method || "GET", + url: groupByDS.endpoint, + headers: (groupByDS as any).headers || {}, + body: (groupByDS as any).body, + authType: (groupByDS as any).authType, + authConfig: (groupByDS as any).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: any) => ({ + 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 as any).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 as any).method || "GET", + url: element.dataSource.endpoint, + headers: (element.dataSource as any).headers || {}, + body: (element.dataSource as any).body, + authType: (element.dataSource as any).authType, + authConfig: (element.dataSource as any).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 ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + // 데이터 소스 체크 + const hasMetricsDataSource = + (element?.dataSource?.type === "database" && element?.dataSource?.query) || + (element?.dataSource?.type === "api" && element?.dataSource?.endpoint); + + 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 ( +
+
+

사용자 커스텀 카드

+
+

📊 맞춤형 지표 위젯

+
    +
  • • SQL 쿼리로 데이터를 불러옵니다
  • +
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • +
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • +
  • • 사용자 정의 단위 설정 가능
  • +
  • + • 그룹별 카드 생성 모드로 간편하게 사용 가능 +
  • +
+
+
+

⚙️ 설정 방법

+

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

+ {isGroupByMode &&

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

} +
+
+
+ ); + } + + // 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로) + // 실제 측정된 1칸 높이: 119px + const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진) + + return ( +
+ {/* 그룹별 카드 (활성화 시) */} + {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); + + return ( +
+
{metric.label}
+
+ {formattedValue} + {metric.unit} +
+
+ ); + })} +
+ ); +}